perf: optimize loading messages in threads (#552)

* perf: improve lazy loading and remove spinner

* perf: optimize message loading by caching and reducing queries

* docs: update changelog

* style: use constant for cache size

* refactor: minor consistency improvement

* fix: override loaded preview size

* refactor: streamline message loading logic in scroll listener

* refactor: organize some dedup related code

* build: bump detekt return count limit

2 is 2 low

* fix: check contacts permissions before registering observer

* fix: disable fetching media resolutions in threads

* refactor: remove resolution fetching related code

* perf: cache MMS thread participants

* refactor: remove unused BitmapFactory import

* fix: invalidate participants cache when necessary

* fix: return copied participants from cache

* fix: adjust image loading dimensions in threads

* fix: use stable ids for header items

* fix: always rely on database check before flipping `allMessagesFetched`
This commit is contained in:
Naveen Singh
2025-10-15 00:46:45 +05:30
committed by GitHub
parent d6160b8448
commit 72eb0af8ec
16 changed files with 283 additions and 251 deletions

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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())
}

View File

@@ -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<Message>
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<ThreadItem>,
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<Uri>)?.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
}
}

View File

@@ -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<ThreadItem>
private fun getSelectedItems(): ArrayList<ThreadItem> {
return currentList.filter {
selectedKeys.contains((it as? Message)?.getSelectionKey() ?: 0)
} as ArrayList<ThreadItem>
}
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<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>, isFirstResource: Boolean): Boolean {
threadMessagePlayOutline.beGone()
@@ -435,23 +492,11 @@ class ThreadAdapter(
override fun onResourceReady(dr: Drawable, a: Any, t: Target<Drawable>, 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<ViewGroup.LayoutParams> {
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<ThreadItem>() {
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

View File

@@ -32,3 +32,20 @@ fun Map<String, Any>.toContentValues(): ContentValues {
}
fun <T> Collection<T>.toArrayList() = ArrayList(this)
inline fun <T> Collection<T>.filterNotInByKey(
existing: List<T>,
crossinline key: (T) -> Long
): ArrayList<T> {
if (isEmpty()) return arrayListOf()
if (existing.isEmpty()) {
return ArrayList(this)
}
val seen = HashSet<Long>(existing.size * 2)
for (item in existing) {
seen.add(key(item))
}
return filter { seen.add(key(it)) }.toArrayList()
}

View File

@@ -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<Message>()
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<Message> {
@@ -256,7 +246,6 @@ fun Context.getMMS(
val messages = ArrayList<Message>()
val contactsMap = HashMap<Int, SimpleContact>()
val threadParticipants = HashMap<Long, ArrayList<SimpleContact>>()
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<Long> {
// 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<Int, SimpleContact>?,
): ArrayList<SimpleContact> {
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)

View File

@@ -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)
}
})
}

View File

@@ -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)
}

View File

@@ -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<String, NamePhoto>(CACHE_SIZE)
val participantsCache = LruCache<Long, ArrayList<SimpleContact>>(CACHE_SIZE)
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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"

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:paddingVertical="@dimen/normal_margin">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/thread_loading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/small_margin"
android:indeterminate="true"
app:indicatorSize="@dimen/big_margin"
app:trackCornerRadius="@dimen/normal_margin" />
</LinearLayout>

View File

@@ -40,6 +40,11 @@ style:
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludeGuardClauses: true
excludes: ["**/test/**", "**/androidTest/**"]
naming:
FunctionNaming: