From 0d6220100dccc069ffca7cd2febed4f83d8c80e1 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:32:44 +0530 Subject: [PATCH] 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 --- CHANGELOG.md | 3 + .../messages/activities/ThreadActivity.kt | 47 ++++++++- .../fossify/messages/extensions/Context.kt | 98 ++++++++++--------- .../messages/helpers/MessagingCache.kt | 68 ++++++++++++- 4 files changed, 167 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7720ac2..9f1ac121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### 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 diff --git a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt index ec26de33..15ae9a2a 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt @@ -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? { + 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() } } diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt index 5ce12421..79c04bfa 100644 --- a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt @@ -618,61 +618,67 @@ fun Context.getThreadParticipants( threadId: Long, contactsMap: HashMap?, ): ArrayList { - MessagingCache.participantsCache.get(threadId)?.let { - return it.map { contact -> - contact.copy( - phoneNumbers = contact.phoneNumbers.toArrayList(), - birthdays = contact.birthdays.toArrayList(), - anniversaries = contact.anniversaries.toArrayList() - ) - }.toArrayList() + val 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() - 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 { + val recipientIds = ArrayList() + 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): ArrayList { val numbers = ArrayList() recipientIds.forEach { diff --git a/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt b/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt index 4a893775..e766059a 100644 --- a/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt +++ b/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt @@ -8,5 +8,71 @@ private const val CACHE_SIZE = 512 object MessagingCache { val namePhoto = LruCache(CACHE_SIZE) - val participantsCache = LruCache>(CACHE_SIZE) + internal val participantsCache = ThreadParticipantsCache(CACHE_SIZE) +} + +internal class ThreadParticipantsCache(private val maxSize: Int) { + private val lock = Any() + private val entries = LruCache(maxSize) + + fun get(threadId: Long, recipientNumbers: List): ArrayList? { + 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, + participants: ArrayList, + ) { + 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, + val participants: ArrayList, + ) +} + +private fun List.toRecipientCacheKey(): List { + return map { it.trim() } +} + +private fun ArrayList.deepCopy(): ArrayList { + return map { contact -> + contact.copy( + phoneNumbers = ArrayList(contact.phoneNumbers), + birthdays = ArrayList(contact.birthdays), + anniversaries = ArrayList(contact.anniversaries) + ) + }.toCollection(ArrayList()) }