fix: invalidate stale thread participants when recipients change (#812)

* fix: invalidate stale thread participants when recipients change

Refs: https://github.com/FossifyOrg/Messages/issues/615

* fix: preserve intent numbers for empty conversation threads
This commit is contained in:
Naveen Singh
2026-07-09 06:32:44 +05:30
committed by GitHub
parent de2595edea
commit 0d6220100d
4 changed files with 167 additions and 49 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]
### Fixed
- Fixed messages being sent to the wrong contact ([#615])
### Fixed
- Fixed incomplete message exports ([#713])
@@ -238,6 +240,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#574]: https://github.com/FossifyOrg/Messages/issues/574
[#600]: https://github.com/FossifyOrg/Messages/issues/600
[#610]: https://github.com/FossifyOrg/Messages/issues/610
[#615]: https://github.com/FossifyOrg/Messages/issues/615
[#641]: https://github.com/FossifyOrg/Messages/issues/641
[#644]: https://github.com/FossifyOrg/Messages/issues/644
[#651]: https://github.com/FossifyOrg/Messages/issues/651

View File

@@ -492,12 +492,13 @@ class ThreadActivity : SimpleActivity() {
}
}
val providerParticipantsChanged = reconcileProviderParticipants()
val hasParticipantWithoutName = participants.any { contact ->
contact.phoneNumbers.map { it.normalizedNumber }.contains(contact.name)
}
try {
if (participants.isNotEmpty() && messages.hashCode() == cachedMessagesCode && !hasParticipantWithoutName) {
if (canReuseLoadedThread(providerParticipantsChanged, cachedMessagesCode, hasParticipantWithoutName)) {
setupAdapter()
runOnUiThread { callback() }
return@ensureBackgroundThread
@@ -564,6 +565,18 @@ class ThreadActivity : SimpleActivity() {
}
}
private fun canReuseLoadedThread(
providerParticipantsChanged: Boolean,
cachedMessagesCode: Int,
hasParticipantWithoutName: Boolean,
): Boolean {
if (providerParticipantsChanged || participants.isEmpty() || hasParticipantWithoutName) {
return false
}
return messages.hashCode() == cachedMessagesCode
}
private fun getOrCreateThreadAdapter(): ThreadAdapter {
var currAdapter = binding.threadMessagesList.adapter
if (currAdapter == null) {
@@ -962,6 +975,32 @@ class ThreadActivity : SimpleActivity() {
}
}
private fun hasOnlyScheduledMessages(): Boolean {
return messages.isNotEmpty() && messages.all { it.isScheduled }
}
private fun getProviderThreadParticipants(): ArrayList<SimpleContact>? {
if (isRecycleBin || threadId <= 0L || hasOnlyScheduledMessages()) {
return null
}
return getThreadParticipants(threadId, null).takeIf { it.isNotEmpty() }
}
private fun reconcileProviderParticipants(): Boolean {
val providerParticipants = getProviderThreadParticipants() ?: return false
val participantsChanged = participants.getAddresses() != providerParticipants.getAddresses()
participants = providerParticipants
if (participantsChanged) {
runOnUiThread {
maybeDisableShortCodeReply()
}
}
return participantsChanged
}
private fun setupParticipants() {
if (participants.isEmpty()) {
participants = if (messages.isEmpty()) {
@@ -969,7 +1008,7 @@ class ThreadActivity : SimpleActivity() {
val participants = getThreadParticipants(threadId, null)
fixParticipantNumbers(participants, intentNumbers)
} else {
messages.first().participants
getProviderThreadParticipants() ?: messages.first().participants
}
runOnUiThread {
maybeDisableShortCodeReply()
@@ -1778,6 +1817,7 @@ class ThreadActivity : SimpleActivity() {
}
val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L
val providerParticipantsChanged = reconcileProviderParticipants()
val newThreadId = getThreadId(participants.getAddresses().toSet())
val newMessages = getMessages(newThreadId, includeScheduledMessages = false)
if (messages.isNotEmpty() && messages.all { it.isScheduled } && newMessages.isNotEmpty()) {
@@ -1806,6 +1846,9 @@ class ThreadActivity : SimpleActivity() {
setupAdapter()
runOnUiThread {
if (providerParticipantsChanged) {
setupThreadTitle()
}
setupSIMSelector()
}
}

View File

@@ -618,61 +618,67 @@ 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 recipientIds = getThreadRecipientIds(threadId)
val phoneNumbers = getThreadPhoneNumbers(recipientIds)
MessagingCache.participantsCache.get(threadId, phoneNumbers)?.let {
return it
}
val uri = "${MmsSms.CONTENT_CONVERSATIONS_URI}?simple=true".toUri()
val projection = arrayOf(
ThreadsColumns.RECIPIENT_IDS
)
val selection = "${Mms._ID} = ?"
val selectionArgs = arrayOf(threadId.toString())
val participants = ArrayList<SimpleContact>()
try {
val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null)
cursor?.use {
if (cursor.moveToFirst()) {
val address = cursor.getStringValue(ThreadsColumns.RECIPIENT_IDS)
address.split(" ").filter { it.areDigitsOnly() }.forEach {
val addressId = it.toInt()
if (contactsMap?.containsKey(addressId) == true) {
participants.add(contactsMap[addressId]!!)
return@forEach
}
val number = getPhoneNumberFromAddressId(addressId)
val namePhoto = getNameAndPhotoFromPhoneNumber(number)
val name = namePhoto.name
val photoUri = namePhoto.photoUri ?: ""
val phoneNumber = PhoneNumber(number, 0, "", number)
val contact = SimpleContact(
rawId = addressId,
contactId = addressId,
name = name,
photoUri = photoUri,
phoneNumbers = arrayListOf(phoneNumber),
birthdays = ArrayList(),
anniversaries = ArrayList()
)
participants.add(contact)
}
}
recipientIds.zip(phoneNumbers).forEach { (addressId, number) ->
val cachedContact = contactsMap?.get(addressId)
val threadPhoneNumber = cachedContact
?.phoneNumbers
?.firstOrNull { it.normalizedNumber == number }
if (cachedContact != null && threadPhoneNumber != null) {
participants.add(
cachedContact.copy(
phoneNumbers = arrayListOf(threadPhoneNumber.copy()),
birthdays = ArrayList(cachedContact.birthdays),
anniversaries = ArrayList(cachedContact.anniversaries)
)
)
return@forEach
}
} catch (e: Exception) {
showErrorToast(e)
val namePhoto = getNameAndPhotoFromPhoneNumber(number)
val name = namePhoto.name
val photoUri = namePhoto.photoUri ?: ""
val phoneNumber = PhoneNumber(number, 0, "", number)
val contact = SimpleContact(
rawId = addressId,
contactId = addressId,
name = name,
photoUri = photoUri,
phoneNumbers = arrayListOf(phoneNumber),
birthdays = ArrayList(),
anniversaries = ArrayList()
)
participants.add(contact)
}
MessagingCache.participantsCache.put(threadId, participants)
MessagingCache.participantsCache.put(threadId, phoneNumbers, participants)
return participants
}
private fun Context.getThreadRecipientIds(threadId: Long): List<Int> {
val recipientIds = ArrayList<Int>()
queryCursor(
uri = "${MmsSms.CONTENT_CONVERSATIONS_URI}?simple=true".toUri(),
projection = arrayOf(ThreadsColumns.RECIPIENT_IDS),
selection = "${Mms._ID} = ?",
selectionArgs = arrayOf(threadId.toString()),
showErrors = true
) { cursor ->
cursor.getStringValue(ThreadsColumns.RECIPIENT_IDS)
.split(" ")
.filter { it.areDigitsOnly() }
.forEach { recipientIds.add(it.toInt()) }
}
return recipientIds
}
fun Context.getThreadPhoneNumbers(recipientIds: List<Int>): ArrayList<String> {
val numbers = ArrayList<String>()
recipientIds.forEach {

View File

@@ -8,5 +8,71 @@ private const val CACHE_SIZE = 512
object MessagingCache {
val namePhoto = LruCache<String, NamePhoto>(CACHE_SIZE)
val participantsCache = LruCache<Long, ArrayList<SimpleContact>>(CACHE_SIZE)
internal val participantsCache = ThreadParticipantsCache(CACHE_SIZE)
}
internal class ThreadParticipantsCache(private val maxSize: Int) {
private val lock = Any()
private val entries = LruCache<Long, Entry>(maxSize)
fun get(threadId: Long, recipientNumbers: List<String>): ArrayList<SimpleContact>? {
val key = recipientNumbers.toRecipientCacheKey()
synchronized(lock) {
val entry = entries.get(threadId) ?: return null
if (entry.recipientNumbers != key) {
entries.remove(threadId)
return null
}
return entry.participants.deepCopy()
}
}
fun put(
threadId: Long,
recipientNumbers: List<String>,
participants: ArrayList<SimpleContact>,
) {
if (participants.isEmpty()) return
synchronized(lock) {
entries.put(
threadId,
Entry(
recipientNumbers = recipientNumbers.toRecipientCacheKey(),
participants = participants.deepCopy()
)
)
}
}
fun remove(threadId: Long) {
synchronized(lock) {
entries.remove(threadId)
}
}
fun evictAll() {
synchronized(lock) {
entries.evictAll()
}
}
private data class Entry(
val recipientNumbers: List<String>,
val participants: ArrayList<SimpleContact>,
)
}
private fun List<String>.toRecipientCacheKey(): List<String> {
return map { it.trim() }
}
private fun ArrayList<SimpleContact>.deepCopy(): ArrayList<SimpleContact> {
return map { contact ->
contact.copy(
phoneNumbers = ArrayList(contact.phoneNumbers),
birthdays = ArrayList(contact.birthdays),
anniversaries = ArrayList(contact.anniversaries)
)
}.toCollection(ArrayList())
}