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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Optimized loading messages in conversations
## [1.4.0] - 2025-10-12 ## [1.4.0] - 2025-10-12
### Added ### Added

View file

@ -1,7 +1,38 @@
package org.fossify.messages 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.FossifyApp
import org.fossify.commons.extensions.hasPermission
import org.fossify.commons.helpers.PERMISSION_READ_CONTACTS
import org.fossify.messages.helpers.MessagingCache
class App : FossifyApp() { class App : FossifyApp() {
override val isAppLockFeatureAvailable = true 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) { if (config.appRunCount == 1) {
conversations.map { it.threadId }.forEach { threadId -> conversations.map { it.threadId }.forEach { threadId ->
val messages = getMessages( val messages = getMessages(threadId, includeScheduledMessages = false)
threadId = threadId,
getImageResolutions = false,
includeScheduledMessages = false
)
messages.chunked(30).forEach { currentMessages -> messages.chunked(30).forEach { currentMessages ->
messagesDB.insertMessages(*currentMessages.toTypedArray()) 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_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.BitmapFactory
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
@ -49,7 +47,6 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import org.fossify.commons.dialogs.ConfirmationDialog 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.realScreenSize
import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.showKeyboard import org.fossify.commons.extensions.showKeyboard
import org.fossify.commons.extensions.toInt
import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.toast
import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.updateTextColors
import org.fossify.commons.extensions.value 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.PhoneNumber
import org.fossify.commons.models.RadioItem import org.fossify.commons.models.RadioItem
import org.fossify.commons.models.SimpleContact import org.fossify.commons.models.SimpleContact
import org.fossify.commons.views.MyRecyclerView
import org.fossify.messages.BuildConfig import org.fossify.messages.BuildConfig
import org.fossify.messages.R import org.fossify.messages.R
import org.fossify.messages.adapters.AttachmentsAdapter 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.markThreadMessagesUnread
import org.fossify.messages.extensions.messagesDB import org.fossify.messages.extensions.messagesDB
import org.fossify.messages.extensions.moveMessageToRecycleBin import org.fossify.messages.extensions.moveMessageToRecycleBin
import org.fossify.messages.extensions.onScroll
import org.fossify.messages.extensions.removeDiacriticsIfNeeded import org.fossify.messages.extensions.removeDiacriticsIfNeeded
import org.fossify.messages.extensions.renameConversation import org.fossify.messages.extensions.renameConversation
import org.fossify.messages.extensions.restoreAllMessagesFromRecycleBinForConversation 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
import org.fossify.messages.models.ThreadItem.ThreadDateTime import org.fossify.messages.models.ThreadItem.ThreadDateTime
import org.fossify.messages.models.ThreadItem.ThreadError 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.ThreadSending
import org.fossify.messages.models.ThreadItem.ThreadSent import org.fossify.messages.models.ThreadItem.ThreadSent
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
@ -200,16 +195,11 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.joda.time.DateTime import org.joda.time.DateTime
import java.io.File import java.io.File
import androidx.core.net.toUri
import androidx.recyclerview.widget.RecyclerView
import org.fossify.messages.extensions.filterNotInByKey
class ThreadActivity : SimpleActivity() { 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 threadId = 0L
private var currentSIMCardIndex = 0 private var currentSIMCardIndex = 0
private var isActivityVisible = false private var isActivityVisible = false
@ -489,12 +479,10 @@ class ThreadActivity : SimpleActivity() {
val cachedMessagesCode = messages.clone().hashCode() val cachedMessagesCode = messages.clone().hashCode()
if (!isRecycleBin) { if (!isRecycleBin) {
messages = getMessages(threadId, true) messages = getMessages(threadId)
if (config.useRecycleBin) { if (config.useRecycleBin) {
val recycledMessages = val recycledMessages = messagesDB.getThreadMessagesFromRecycleBin(threadId)
messagesDB.getThreadMessagesFromRecycleBin(threadId).map { it.id } messages = messages.filterNotInByKey(recycledMessages) { it.getStableId() }
messages = messages.filter { !recycledMessages.contains(it.id) }
.toMutableList() as ArrayList<Message>
} }
} }
@ -560,7 +548,6 @@ class ThreadActivity : SimpleActivity() {
} }
} }
setupAttachmentSizes()
setupAdapter() setupAdapter()
runOnUiThread { runOnUiThread {
setupThreadTitle() setupThreadTitle()
@ -587,14 +574,6 @@ class ThreadActivity : SimpleActivity() {
) )
binding.threadMessagesList.adapter = currAdapter binding.threadMessagesList.adapter = currAdapter
binding.threadMessagesList.endlessScrollListener =
object : MyRecyclerView.EndlessScrollListener {
override fun updateBottom() {}
override fun updateTop() {
fetchNextMessages()
}
}
} }
return currAdapter as ThreadAdapter return currAdapter as ThreadAdapter
} }
@ -663,21 +642,21 @@ class ThreadActivity : SimpleActivity() {
} }
} }
private fun setupScrollFab() { private fun setupScrollListener() {
binding.threadMessagesList.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.threadMessagesList.onScroll(
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { onScrolled = { dx, dy ->
super.onScrolled(recyclerView, dx, dy) tryLoadMoreMessages()
val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition() val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition()
val isCloseToBottom = val isCloseToBottom =
lastVisibleItemPosition >= getOrCreateThreadAdapter().itemCount - SCROLL_TO_BOTTOM_FAB_LIMIT lastVisibleItemPosition >= getOrCreateThreadAdapter().itemCount - SCROLL_TO_BOTTOM_FAB_LIMIT
if (isCloseToBottom) { val fab = binding.scrollToBottomFab
binding.scrollToBottomFab.hide() if (isCloseToBottom) fab.hide() else fab.show()
} else { },
binding.scrollToBottomFab.show() onScrollStateChanged = { newState ->
if (newState == RecyclerView.SCROLL_STATE_IDLE) tryLoadMoreMessages()
} }
} )
})
} }
private fun handleItemClick(any: Any) { private fun handleItemClick(any: Any) {
@ -737,35 +716,25 @@ class ThreadActivity : SimpleActivity() {
} }
} }
private fun fetchNextMessages() { private fun tryLoadMoreMessages() {
if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) { val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager
if (allMessagesFetched) { if (layoutManager.findFirstVisibleItemPosition() <= PREFETCH_THRESHOLD) {
getOrCreateThreadAdapter().apply { loadMoreMessages()
val newList = currentList.toMutableList().apply {
removeAll { it is ThreadLoading }
}
updateMessages(
newMessages = newList as ArrayList<ThreadItem>,
scrollPosition = 0
)
} }
} }
return
} private fun loadMoreMessages() {
if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) return
val firstItem = messages.first() val firstItem = messages.first()
val dateOfFirstItem = firstItem.date val dateOfFirstItem = firstItem.date
if (oldestMessageDate == dateOfFirstItem) {
allMessagesFetched = true
return
}
oldestMessageDate = dateOfFirstItem oldestMessageDate = dateOfFirstItem
loadingOlderMessages = true loadingOlderMessages = true
ensureBackgroundThread { ensureBackgroundThread {
val olderMessages = getMessages(threadId, true, oldestMessageDate) val olderMessages = getMessages(threadId, oldestMessageDate)
.filter { message -> !messages.contains(message) } .filterNotInByKey(messages) { it.getStableId() }
messages.addAll(0, olderMessages) messages.addAll(0, olderMessages)
allMessagesFetched = olderMessages.isEmpty() allMessagesFetched = olderMessages.isEmpty()
@ -773,8 +742,7 @@ class ThreadActivity : SimpleActivity() {
runOnUiThread { runOnUiThread {
loadingOlderMessages = false loadingOlderMessages = false
val itemAtRefreshIndex = threadItems.indexOfFirst { it == firstItem } getOrCreateThreadAdapter().updateMessages(threadItems)
getOrCreateThreadAdapter().updateMessages(threadItems, itemAtRefreshIndex)
} }
} }
} }
@ -796,7 +764,7 @@ class ThreadActivity : SimpleActivity() {
} }
setupThread() setupThread()
setupScrollFab() setupScrollListener()
} }
} else { } else {
finish() finish()
@ -913,7 +881,7 @@ class ThreadActivity : SimpleActivity() {
} }
if (intent.extras?.containsKey(THREAD_ATTACHMENT_URI) == true) { 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) addAttachment(uri)
} else if (intent.extras?.containsKey(THREAD_ATTACHMENT_URIS) == true) { } else if (intent.extras?.containsKey(THREAD_ATTACHMENT_URIS) == true) {
(intent.getSerializableExtra(THREAD_ATTACHMENT_URIS) as? ArrayList<Uri>)?.forEach { (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() { private fun setupParticipants() {
if (participants.isEmpty()) { if (participants.isEmpty()) {
participants = if (messages.isEmpty()) { participants = if (messages.isEmpty()) {
@ -1346,11 +1276,6 @@ class ThreadActivity : SimpleActivity() {
bus?.post(Events.RefreshMessages()) bus?.post(Events.RefreshMessages())
} }
if (!allMessagesFetched && messages.size >= MESSAGES_LIMIT) {
val threadLoading = ThreadLoading(generateRandomId())
items.add(0, threadLoading)
}
return items return items
} }
@ -1615,13 +1540,8 @@ class ThreadActivity : SimpleActivity() {
refreshedSinceSent = false refreshedSinceSent = false
sendMessageCompat(text, addresses, subscriptionId, attachments, messageToResend) sendMessageCompat(text, addresses, subscriptionId, attachments, messageToResend)
ensureBackgroundThread { ensureBackgroundThread {
val messageIds = messages.map { it.id } val messages = getMessages(threadId, limit = maxOf(1, attachments.size))
val messages = getMessages( .filterNotInByKey(messages) { it.getStableId() }
threadId = threadId,
getImageResolutions = true,
limit = maxOf(1, attachments.size)
)
.filter { it.id !in messageIds }
for (message in messages) { for (message in messages) {
insertOrUpdateMessage(message) insertOrUpdateMessage(message)
} }
@ -1805,9 +1725,7 @@ class ThreadActivity : SimpleActivity() {
val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L
val newThreadId = getThreadId(participants.getAddresses().toSet()) val newThreadId = getThreadId(participants.getAddresses().toSet())
val newMessages = val newMessages = getMessages(newThreadId, includeScheduledMessages = false)
getMessages(newThreadId, getImageResolutions = true, includeScheduledMessages = false)
if (messages.isNotEmpty() && messages.all { it.isScheduled } && newMessages.isNotEmpty()) { if (messages.isNotEmpty() && messages.all { it.isScheduled } && newMessages.isNotEmpty()) {
// update scheduled messages with real thread id // update scheduled messages with real thread id
threadId = newThreadId threadId = newThreadId
@ -2145,4 +2063,13 @@ class ThreadActivity : SimpleActivity() {
} else { } else {
getBottomNavigationBackgroundColor() 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.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Size
import android.util.TypedValue import android.util.TypedValue
import android.view.Menu import android.view.Menu
import android.view.View import android.view.View
@ -14,21 +13,34 @@ import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException 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.load.resource.bitmap.FitCenter
import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import org.fossify.commons.adapters.MyRecyclerViewListAdapter import org.fossify.commons.adapters.MyRecyclerViewListAdapter
import org.fossify.commons.dialogs.ConfirmationDialog 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.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.views.MyRecyclerView 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.SimpleActivity
import org.fossify.messages.activities.ThreadActivity import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.activities.VCardViewerActivity 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.DeleteConfirmationDialog
import org.fossify.messages.dialogs.MessageDetailsDialog import org.fossify.messages.dialogs.MessageDetailsDialog
import org.fossify.messages.dialogs.SelectTextDialog import org.fossify.messages.dialogs.SelectTextDialog
import org.fossify.messages.extensions.* import org.fossify.messages.extensions.config
import org.fossify.messages.helpers.* 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.Attachment
import org.fossify.messages.models.Message import org.fossify.messages.models.Message
import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem
import org.fossify.messages.models.ThreadItem.* import org.fossify.messages.models.ThreadItem.ThreadDateTime
import androidx.core.graphics.drawable.toDrawable import org.fossify.messages.models.ThreadItem.ThreadError
import org.fossify.messages.models.ThreadItem.ThreadSending
import org.fossify.messages.models.ThreadItem.ThreadSent
class ThreadAdapter( class ThreadAdapter(
activity: SimpleActivity, activity: SimpleActivity,
@ -60,11 +97,18 @@ class ThreadAdapter(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1 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 { init {
setupDragListener(true) setupDragListener(true)
setHasStableIds(true) setHasStableIds(true)
(recyclerView.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
} }
override fun getActionMenuId() = R.menu.cab_thread override fun getActionMenuId() = R.menu.cab_thread
@ -110,9 +154,13 @@ class ThreadAdapter(
override fun getIsItemSelectable(position: Int) = !isThreadDateTime(position) 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() {} override fun onActionModeCreated() {}
@ -120,7 +168,6 @@ class ThreadAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = when (viewType) { val binding = when (viewType) {
THREAD_LOADING -> ItemThreadLoadingBinding.inflate(layoutInflater, parent, false)
THREAD_DATE_TIME -> ItemThreadDateTimeBinding.inflate(layoutInflater, parent, false) THREAD_DATE_TIME -> ItemThreadDateTimeBinding.inflate(layoutInflater, parent, false)
THREAD_SENT_MESSAGE_ERROR -> ItemThreadErrorBinding.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_SENT -> ItemThreadSuccessBinding.inflate(layoutInflater, parent, false)
@ -137,7 +184,6 @@ class ThreadAdapter(
val isLongClickable = item is Message val isLongClickable = item is Message
holder.bindView(item, isClickable, isLongClickable) { itemView, _ -> holder.bindView(item, isClickable, isLongClickable) { itemView, _ ->
when (item) { when (item) {
is ThreadLoading -> setupThreadLoading(itemView)
is ThreadDateTime -> setupDateTime(itemView, item) is ThreadDateTime -> setupDateTime(itemView, item)
is ThreadError -> setupThreadError(itemView) is ThreadError -> setupThreadError(itemView)
is ThreadSent -> setupThreadSuccess(itemView, item.delivered) is ThreadSent -> setupThreadSuccess(itemView, item.delivered)
@ -150,14 +196,20 @@ class ThreadAdapter(
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
return when (val item = getItem(position)) { return when (val item = getItem(position)) {
is Message -> Message.getStableId(item) is Message -> item.getStableId()
else -> item.hashCode().toLong() 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 { override fun getItemViewType(position: Int): Int {
return when (val item = getItem(position)) { return when (val item = getItem(position)) {
is ThreadLoading -> THREAD_LOADING
is ThreadDateTime -> THREAD_DATE_TIME is ThreadDateTime -> THREAD_DATE_TIME
is ThreadError -> THREAD_SENT_MESSAGE_ERROR is ThreadError -> THREAD_SENT_MESSAGE_ERROR
is ThreadSent -> THREAD_SENT_MESSAGE_SENT 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 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) { private fun setupView(holder: ViewHolder, view: View, message: Message) {
ItemMessageBinding.bind(view).apply { ItemMessageBinding.bind(view).apply {
threadMessageHolder.isSelected = selectedKeys.contains(message.hashCode()) threadMessageHolder.isSelected = selectedKeys.contains(message.getSelectionKey())
threadMessageBody.apply { threadMessageBody.apply {
text = message.body text = message.body
setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize)
@ -416,16 +472,17 @@ class ThreadAdapter(
threadMessageAttachmentsHolder.addView(imageView.root) threadMessageAttachmentsHolder.addView(imageView.root)
val placeholderDrawable = Color.TRANSPARENT.toDrawable() val placeholderDrawable = Color.TRANSPARENT.toDrawable()
val isTallImage = attachment.height > attachment.width
val transformation = if (isTallImage) CenterCrop() else FitCenter()
val options = RequestOptions() val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.placeholder(placeholderDrawable) .placeholder(placeholderDrawable)
.transform(transformation) .transform(FitCenter())
var builder = Glide.with(root.context) Glide.with(root.context)
.load(uri) .load(uri)
.apply(options) .apply(options)
.dontAnimate()
.override(maxChatBubbleWidth, maxChatBubbleWidth * MAX_MEDIA_HEIGHT_RATIO)
.downsample(DownsampleStrategy.AT_MOST)
.listener(object : RequestListener<Drawable> { .listener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>, isFirstResource: Boolean): Boolean { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>, isFirstResource: Boolean): Boolean {
threadMessagePlayOutline.beGone() threadMessagePlayOutline.beGone()
@ -435,23 +492,11 @@ class ThreadAdapter(
override fun onResourceReady(dr: Drawable, a: Any, t: Target<Drawable>, d: DataSource, i: Boolean) = false 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 imageView.attachmentImage.updateLayoutParams<ViewGroup.LayoutParams> {
var wantedAttachmentSize = Size(attachment.width, attachment.height) width = maxChatBubbleWidth
if (wantedAttachmentSize.width > maxChatBubbleWidth) { height = ViewGroup.LayoutParams.WRAP_CONTENT
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.setOnClickListener { 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) { override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder) super.onViewRecycled(holder)
if (!activity.isDestroyed && !activity.isFinishing) { if (!activity.isDestroyed && !activity.isFinishing) {
@ -576,19 +616,21 @@ private class ThreadItemDiffCallback : DiffUtil.ItemCallback<ThreadItem>() {
override fun areItemsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean { override fun areItemsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false if (oldItem::class.java != newItem::class.java) return false
return when (oldItem) { 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 ThreadError -> oldItem.messageId == (newItem as ThreadError).messageId
is ThreadSent -> oldItem.messageId == (newItem as ThreadSent).messageId is ThreadSent -> oldItem.messageId == (newItem as ThreadSent).messageId
is ThreadSending -> oldItem.messageId == (newItem as ThreadSending).messageId is ThreadSending -> oldItem.messageId == (newItem as ThreadSending).messageId
is Message -> Message.areItemsTheSame(oldItem, newItem as Message) 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 { override fun areContentsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean {
if (oldItem::class.java != newItem::class.java) return false if (oldItem::class.java != newItem::class.java) return false
return when (oldItem) { return when (oldItem) {
is ThreadLoading, is ThreadSending -> true is ThreadSending -> true
is ThreadDateTime -> oldItem.simID == (newItem as ThreadDateTime).simID is ThreadDateTime -> oldItem.simID == (newItem as ThreadDateTime).simID
is ThreadError -> oldItem.messageText == (newItem as ThreadError).messageText is ThreadError -> oldItem.messageText == (newItem as ThreadError).messageText
is ThreadSent -> oldItem.delivered == (newItem as ThreadSent).delivered 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) 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.Cursor
import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteException
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper 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.FILE_SIZE_NONE
import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH
import org.fossify.messages.helpers.MESSAGES_LIMIT import org.fossify.messages.helpers.MESSAGES_LIMIT
import org.fossify.messages.helpers.MessagingCache
import org.fossify.messages.helpers.NotificationHelper import org.fossify.messages.helpers.NotificationHelper
import org.fossify.messages.helpers.ShortcutHelper import org.fossify.messages.helpers.ShortcutHelper
import org.fossify.messages.helpers.generateRandomId import org.fossify.messages.helpers.generateRandomId
@ -111,7 +111,6 @@ val Context.shortcutHelper get() = ShortcutHelper(this)
fun Context.getMessages( fun Context.getMessages(
threadId: Long, threadId: Long,
getImageResolutions: Boolean,
dateFrom: Int = -1, dateFrom: Int = -1,
includeScheduledMessages: Boolean = true, includeScheduledMessages: Boolean = true,
limit: Int = MESSAGES_LIMIT, limit: Int = MESSAGES_LIMIT,
@ -139,15 +138,7 @@ fun Context.getMessages(
var messages = ArrayList<Message>() var messages = ArrayList<Message>()
queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor -> queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor ->
val senderNumber = cursor.getStringValue(Sms.ADDRESS) ?: return@queryCursor val senderNumber = cursor.getStringValue(Sms.ADDRESS) ?: return@queryCursor
val isNumberBlocked = blockStatus.getOrPut(senderNumber) { isNumberBlocked(senderNumber, blockedNumbers) }
val isNumberBlocked = if (blockStatus.containsKey(senderNumber)) {
blockStatus[senderNumber]!!
} else {
val isBlocked = isNumberBlocked(senderNumber, blockedNumbers)
blockStatus[senderNumber] = isBlocked
isBlocked
}
if (isNumberBlocked) { if (isNumberBlocked) {
return@queryCursor return@queryCursor
} }
@ -201,7 +192,7 @@ fun Context.getMessages(
messages.add(message) messages.add(message)
} }
messages.addAll(getMMS(threadId, getImageResolutions, sortOrder, dateFrom)) messages.addAll(getMMS(threadId, sortOrder, dateFrom))
if (includeScheduledMessages) { if (includeScheduledMessages) {
try { try {
@ -225,7 +216,6 @@ fun Context.getMessages(
// as soon as a message contains multiple recipients it counts as an MMS instead of SMS // as soon as a message contains multiple recipients it counts as an MMS instead of SMS
fun Context.getMMS( fun Context.getMMS(
threadId: Long? = null, threadId: Long? = null,
getImageResolutions: Boolean = false,
sortOrder: String? = null, sortOrder: String? = null,
dateFrom: Int = -1, dateFrom: Int = -1,
): ArrayList<Message> { ): ArrayList<Message> {
@ -256,7 +246,6 @@ fun Context.getMMS(
val messages = ArrayList<Message>() val messages = ArrayList<Message>()
val contactsMap = HashMap<Int, SimpleContact>() val contactsMap = HashMap<Int, SimpleContact>()
val threadParticipants = HashMap<Long, ArrayList<SimpleContact>>()
queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor -> queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor ->
val mmsId = cursor.getLongValue(Mms._ID) val mmsId = cursor.getLongValue(Mms._ID)
val type = cursor.getIntValue(Mms.MESSAGE_BOX) val type = cursor.getIntValue(Mms.MESSAGE_BOX)
@ -265,16 +254,10 @@ fun Context.getMMS(
val threadId = cursor.getLongValue(Mms.THREAD_ID) val threadId = cursor.getLongValue(Mms.THREAD_ID)
val subscriptionId = cursor.getIntValue(Mms.SUBSCRIPTION_ID) val subscriptionId = cursor.getIntValue(Mms.SUBSCRIPTION_ID)
val status = cursor.getIntValue(Mms.STATUS) val status = cursor.getIntValue(Mms.STATUS)
val participants = if (threadParticipants.containsKey(threadId)) { val participants = getThreadParticipants(threadId, contactsMap)
threadParticipants[threadId]!!
} else {
val parts = getThreadParticipants(threadId, contactsMap)
threadParticipants[threadId] = parts
parts
}
val isMMS = true val isMMS = true
val attachment = getMmsAttachment(mmsId, getImageResolutions) val attachment = getMmsAttachment(mmsId)
val body = attachment.text val body = attachment.text
var senderNumber = "" var senderNumber = ""
var senderName = "" var senderName = ""
@ -478,7 +461,7 @@ fun Context.getConversationIds(): List<Long> {
// based on https://stackoverflow.com/a/6446831/1967672 // based on https://stackoverflow.com/a/6446831/1967672
@SuppressLint("NewApi") @SuppressLint("NewApi")
fun Context.getMmsAttachment(id: Long, getImageResolutions: Boolean): MessageAttachment { fun Context.getMmsAttachment(id: Long): MessageAttachment {
val uri = if (isQPlus()) { val uri = if (isQPlus()) {
Mms.Part.CONTENT_URI Mms.Part.CONTENT_URI
} else { } else {
@ -506,32 +489,14 @@ fun Context.getMmsAttachment(id: Long, getImageResolutions: Boolean): MessageAtt
.orEmpty() .orEmpty()
} else if (mimetype.startsWith("image/") || mimetype.startsWith("video/")) { } else if (mimetype.startsWith("image/") || mimetype.startsWith("video/")) {
val fileUri = Uri.withAppendedPath(uri, partId.toString()) 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( messageAttachment.attachments.add(
Attachment( Attachment(
id = partId, id = partId,
messageId = id, messageId = id,
uriString = fileUri.toString(), uriString = fileUri.toString(),
mimetype = mimetype, mimetype = mimetype,
width = width, width = 0,
height = height, height = 0,
filename = "" filename = ""
) )
) )
@ -569,7 +534,7 @@ fun Context.getLatestMMS(): Message? {
fun Context.getThreadSnippet(threadId: Long): String { fun Context.getThreadSnippet(threadId: Long): String {
val sortOrder = "${Mms.DATE} DESC LIMIT 1" val sortOrder = "${Mms.DATE} DESC LIMIT 1"
val latestMms = getMMS(threadId, false, sortOrder).firstOrNull() val latestMms = getMMS(threadId, sortOrder).firstOrNull()
var snippet = latestMms?.body ?: "" var snippet = latestMms?.body ?: ""
val uri = Sms.CONTENT_URI val uri = Sms.CONTENT_URI
@ -620,6 +585,16 @@ fun Context.getThreadParticipants(
threadId: Long, threadId: Long,
contactsMap: HashMap<Int, SimpleContact>?, contactsMap: HashMap<Int, SimpleContact>?,
): ArrayList<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 uri = "${MmsSms.CONTENT_CONVERSATIONS_URI}?simple=true".toUri()
val projection = arrayOf( val projection = arrayOf(
ThreadsColumns.RECIPIENT_IDS ThreadsColumns.RECIPIENT_IDS
@ -660,6 +635,8 @@ fun Context.getThreadParticipants(
} catch (e: Exception) { } catch (e: Exception) {
showErrorToast(e) showErrorToast(e)
} }
MessagingCache.participantsCache.put(threadId, participants)
return participants return participants
} }
@ -768,6 +745,7 @@ fun Context.getSuggestedContacts(
} }
fun Context.getNameAndPhotoFromPhoneNumber(number: String): NamePhoto { fun Context.getNameAndPhotoFromPhoneNumber(number: String): NamePhoto {
MessagingCache.namePhoto.get(number)?.let { return it }
if (!hasPermission(PERMISSION_READ_CONTACTS)) { if (!hasPermission(PERMISSION_READ_CONTACTS)) {
return NamePhoto(number, null) return NamePhoto(number, null)
} }
@ -778,19 +756,23 @@ fun Context.getNameAndPhotoFromPhoneNumber(number: String): NamePhoto {
PhoneLookup.PHOTO_URI PhoneLookup.PHOTO_URI
) )
try { val result = try {
val cursor = contentResolver.query(uri, projection, null, null, null) val cursor = contentResolver.query(uri, projection, null, null, null)
cursor.use { cursor.use {
if (cursor?.moveToFirst() == true) { if (cursor?.moveToFirst() == true) {
val name = cursor.getStringValue(PhoneLookup.DISPLAY_NAME) val name = cursor.getStringValue(PhoneLookup.DISPLAY_NAME)
val photoUri = cursor.getStringValue(PhoneLookup.PHOTO_URI) val photoUri = cursor.getStringValue(PhoneLookup.PHOTO_URI)
return NamePhoto(name, photoUri) NamePhoto(name, photoUri)
} else {
NamePhoto(number, null)
} }
} }
} catch (_: Exception) { } catch (_: Exception) {
NamePhoto(number, null)
} }
return NamePhoto(number, null) MessagingCache.namePhoto.put(number, result)
return result
} }
fun Context.insertNewSMS( fun Context.insertNewSMS(
@ -856,6 +838,7 @@ fun Context.deleteConversation(threadId: Long) {
conversationsDB.deleteThreadId(threadId) conversationsDB.deleteThreadId(threadId)
messagesDB.deleteThreadMessages(threadId) messagesDB.deleteThreadMessages(threadId)
MessagingCache.participantsCache.remove(threadId)
if (config.customNotifications.contains(threadId.toString())) { if (config.customNotifications.contains(threadId.toString())) {
config.removeCustomNotificationsByThreadId(threadId) 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_ERROR = 4
const val THREAD_SENT_MESSAGE_SENT = 5 const val THREAD_SENT_MESSAGE_SENT = 5
const val THREAD_SENT_MESSAGE_SENDING = 6 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 // view types for attachment list
const val ATTACHMENT_DOCUMENT = 7 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_1_MB = 1_048_576L
const val FILE_SIZE_2_MB = 2_097_152L 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 const val MAX_MESSAGE_LENGTH = 5000
// intent launch request codes // intent launch request codes
@ -107,3 +110,8 @@ fun generateRandomId(length: Int = 9): Long {
val random = abs(Random(millis).nextLong()) val random = abs(Random(millis).nextLong())
return random.toString().takeLast(length).toLong() 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.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.fossify.commons.models.SimpleContact 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") @Entity(tableName = "messages")
data class Message( data class Message(
@ -34,22 +37,18 @@ data class Message(
?: participants.firstOrNull { it.name == senderName } ?: participants.firstOrNull { it.name == senderName }
?: participants.firstOrNull() ?: participants.firstOrNull()
companion object { fun getStableId(): Long {
val providerBit = if (isMMS) 1L else 0L
fun getStableId(message: Message): Long { val key = (id shl 1) or providerBit
var result = message.id.hashCode() val type = if (isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE
result = 31 * result + message.body.hashCode() return generateStableId(type, key)
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 getSelectionKey(): Int {
return (id xor (id ushr Int.SIZE_BITS)).toInt()
}
companion object {
fun areItemsTheSame(old: Message, new: Message): Boolean { fun areItemsTheSame(old: Message, new: Message): Boolean {
return old.id == new.id 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] * Thread item representations for the main thread recyclerview. [Message] is also a [ThreadItem]
*/ */
sealed class ThreadItem { sealed class ThreadItem {
data class ThreadLoading(val id: Long) : ThreadItem()
data class ThreadDateTime(val date: Int, val simID: String) : ThreadItem() data class ThreadDateTime(val date: Int, val simID: String) : ThreadItem()
data class ThreadError(val messageId: Long, val messageText: String) : ThreadItem() data class ThreadError(val messageId: Long, val messageText: String) : ThreadItem()
data class ThreadSent(val messageId: Long, val delivered: Boolean) : ThreadItem() data class ThreadSent(val messageId: Long, val delivered: Boolean) : ThreadItem()

View file

@ -40,7 +40,9 @@ class DirectReplyReceiver : BroadcastReceiver() {
var messageId = 0L var messageId = 0L
try { try {
context.sendMessageCompat(body, listOf(address), subscriptionId, emptyList()) 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) { if (message != null) {
context.messagesDB.insertOrUpdate(message) context.messagesDB.insertOrUpdate(message)
messageId = message.id messageId = message.id
@ -54,7 +56,15 @@ class DirectReplyReceiver : BroadcastReceiver() {
val photoUri = SimpleContactsHelper(context).getPhotoUriFromPhoneNumber(address) val photoUri = SimpleContactsHelper(context).getPhotoUriFromPhoneNumber(address)
val bitmap = context.getNotificationBitmap(photoUri) val bitmap = context.getNotificationBitmap(photoUri)
Handler(Looper.getMainLooper()).post { 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) context.markThreadMessagesRead(threadId)

View file

@ -116,6 +116,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:overScrollMode="ifContentScrolls" android:overScrollMode="ifContentScrolls"
android:paddingTop="@dimen/big_margin"
android:paddingBottom="@dimen/medium_margin" android:paddingBottom="@dimen/medium_margin"
android:scrollbars="none" android:scrollbars="none"
app:layoutManager="org.fossify.commons.views.MyLinearLayoutManager" 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 maxLineLength: 120
excludePackageStatements: true excludePackageStatements: true
excludeImportStatements: true excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludeGuardClauses: true
excludes: ["**/test/**", "**/androidTest/**"]
naming: naming:
FunctionNaming: FunctionNaming: