diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/NameCollisionTablesTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/NameCollisionTablesTest.kt new file mode 100644 index 0000000000..ddb83494c5 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/NameCollisionTablesTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.storageservice.protos.groups.Member +import org.signal.storageservice.protos.groups.local.DecryptedMember +import org.thoughtcrime.securesms.mms.IncomingMessage +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.GroupTestingUtils +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assertIsSize + +@RunWith(AndroidJUnit4::class) +class NameCollisionTablesTest { + + @get:Rule + val harness = SignalActivityRule(createGroup = true) + + private lateinit var alice: RecipientId + private lateinit var bob: RecipientId + private lateinit var charlie: RecipientId + + @Before + fun setUp() { + alice = setUpRecipient(harness.others[0]) + bob = setUpRecipient(harness.others[1]) + charlie = setUpRecipient(harness.others[2]) + } + + @Test + fun givenAUserWithAThreadIdButNoConflicts_whenIGetCollisionsForThreadRecipient_thenIExpectNoCollisions() { + val threadRecipientId = alice + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(threadRecipientId)) + val actual = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(threadRecipientId) + + actual assertIsSize 0 + } + + @Test + fun givenTwoUsers_whenOneChangesTheirProfileNameToMatchTheOther_thenIExpectANameCollision() { + setProfileName(alice, ProfileName.fromParts("Alice", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + + val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob) + + actualAlice assertIsSize 2 + actualBob assertIsSize 2 + } + + @Test + fun givenTwoUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectNoNameCollisions() { + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + setProfileName(alice, ProfileName.fromParts("Alice", "Android")) + + val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob) + + actualAlice assertIsSize 0 + actualBob assertIsSize 0 + } + + @Test + fun givenThreeUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectTwoNameCollisions() { + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + setProfileName(charlie, ProfileName.fromParts("Bob", "Android")) + setProfileName(alice, ProfileName.fromParts("Alice", "Android")) + + val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob) + val actualCharlie = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(charlie) + + actualAlice assertIsSize 0 + actualBob assertIsSize 2 + actualCharlie assertIsSize 2 + } + + @Test + fun givenTwoUsersWithADismissedNameCollision_whenOneChangesToADifferentNameAndBack_thenIExpectANameCollision() { + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice) + + setProfileName(alice, ProfileName.fromParts("Alice", "Android")) + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + + val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + + actualAlice assertIsSize 2 + } + + @Test + fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() { + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice) + + val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + + actualCollisions assertIsSize 0 + } + + @Test + fun givenADismissedNameCollisionForAliceThatIUpdate_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() { + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice)) + + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + + val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice) + + actualCollisions assertIsSize 0 + } + + @Test + fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForBob_thenIExpectANameCollisionWithTwoEntries() { + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice)) + + setProfileName(alice, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob, ProfileName.fromParts("Bob", "Android")) + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice) + + val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob) + + actualCollisions assertIsSize 2 + } + + @Test + fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() { + val alice = Recipient.resolved(alice) + val bob = Recipient.resolved(bob) + val info = createGroup() + + setProfileName(alice.id, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob.id, ProfileName.fromParts("Bob", "Android")) + + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId)) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android") + + val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId) + + collisions assertIsSize 2 + } + + @Test + fun givenAGroupWithAliceAndBobWithDismissedCollision_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() { + val alice = Recipient.resolved(alice) + val bob = Recipient.resolved(bob) + val info = createGroup() + + setProfileName(alice.id, ProfileName.fromParts("Bob", "Android")) + setProfileName(bob.id, ProfileName.fromParts("Bob", "Android")) + + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId)) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android") + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(info.recipientId) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android") + + val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId) + + collisions assertIsSize 0 + } + + @Test + fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAliceWithMismatch_thenIExpectNoGroupNameCollision() { + val alice = Recipient.resolved(alice) + val bob = Recipient.resolved(bob) + val info = createGroup() + + setProfileName(alice.id, ProfileName.fromParts("Alice", "Android")) + setProfileName(bob.id, ProfileName.fromParts("Bob", "Android")) + + SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId)) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Alice Android", "Bob Android") + + val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId) + + collisions assertIsSize 0 + } + + private fun setUpRecipient(recipientId: RecipientId): RecipientId { + SignalDatabase.recipients.setProfileSharing(recipientId, false) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false) + + MmsHelper.insert( + threadId = threadId, + message = IncomingMessage( + type = MessageType.NORMAL, + from = recipientId, + groupId = null, + body = "hi", + sentTimeMillis = 100L, + receivedTimeMillis = 200L, + serverTimeMillis = 100L, + isUnidentified = true + ) + ) + + return recipientId + } + + private fun setProfileName(recipientId: RecipientId, name: ProfileName) { + SignalDatabase.recipients.setProfileName(recipientId, name) + SignalDatabase.nameCollisions.handleIndividualNameCollision(recipientId) + } + + private fun createGroup(): GroupTestingUtils.TestGroupInfo { + return GroupTestingUtils.insertGroup( + revision = 0, + DecryptedMember( + aciBytes = harness.self.requireAci().toByteString(), + role = Member.Role.ADMINISTRATOR + ), + DecryptedMember( + aciBytes = Recipient.resolved(alice).requireAci().toByteString(), + role = Member.Role.ADMINISTRATOR + ), + DecryptedMember( + aciBytes = Recipient.resolved(bob).requireAci().toByteString(), + role = Member.Role.ADMINISTRATOR + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index 258783f16e..738e0d7a44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -120,6 +120,7 @@ class ConversationBannerView @JvmOverloads constructor( setOnHideListener { clearRequestReview() + listener?.onDismissReview() true } } @@ -194,5 +195,6 @@ class ConversationBannerView @JvmOverloads constructor( fun onUnverifiedBannerDismissed(unverifiedIdentities: List) fun onRequestReviewIndividual(recipientId: RecipientId) fun onReviewGroupMembers(groupId: GroupId.V2) + fun onDismissReview() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 2fd6cd0ded..b7f55bcc5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -423,7 +423,7 @@ class ConversationFragment : private val conversationGroupViewModel: ConversationGroupViewModel by viewModels( factoryProducer = { - ConversationGroupViewModel.Factory(args.threadId, conversationRecipientRepository) + ConversationGroupViewModel.Factory(conversationRecipientRepository) } ) @@ -3704,6 +3704,10 @@ class ConversationFragment : override fun onReviewGroupMembers(groupId: GroupId.V2) { ReviewCardDialogFragment.createForReviewMembers(groupId).show(childFragmentManager, null) } + + override fun onDismissReview() { + viewModel.onDismissReview() + } } //endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index b0ee8e6ec1..a403faa587 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -82,7 +82,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck -import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil +import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -363,6 +363,12 @@ class ConversationRepository( }.subscribeOn(Schedulers.io()) } + fun dismissRequestReviewState(threadRecipientId: RecipientId) { + SignalExecutors.BOUNDED_IO.execute { + SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(threadRecipientId) + } + } + fun getRequestReviewState(recipient: Recipient, group: GroupRecord?, messageRequest: MessageRequestState): Single { return Single.fromCallable { if (group == null && messageRequest.state != MessageRequestState.State.INDIVIDUAL) { @@ -370,12 +376,12 @@ class ConversationRepository( } if (group == null) { - val recipientsToReview = ReviewUtil.getRecipientsToPromptForReview(recipient.id) - if (recipientsToReview.size > 0) { + val recipientsToReview = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(recipient.id) + if (recipientsToReview.isNotEmpty()) { return@fromCallable RequestReviewState( individualReviewState = IndividualReviewState( target = recipient, - firstDuplicate = Recipient.resolvedList(recipientsToReview)[0] + firstDuplicate = recipientsToReview.first().recipient ) ) } @@ -383,14 +389,14 @@ class ConversationRepository( if (group != null && group.isV2Group) { val groupId = group.id.requireV2() - val duplicateRecipients: List = ReviewUtil.getDuplicatedRecipients(groupId).map { it.recipient } + val duplicateRecipients: List = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(group.recipientId) if (duplicateRecipients.isNotEmpty()) { return@fromCallable RequestReviewState( groupReviewState = GroupReviewState( groupId, - duplicateRecipients[0], - duplicateRecipients[1], + duplicateRecipients[0].recipient, + duplicateRecipients[1].recipient, duplicateRecipients.size ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 695744c056..0de5820bb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -291,6 +291,11 @@ class ConversationViewModel( refreshReminder.onNext(Unit) } + fun onDismissReview() { + val recipientId = recipientSnapshot?.id ?: return + repository.dismissRequestReviewState(recipientId) + } + override fun onCleared() { disposables.clear() startExpiration.onComplete() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt index ebbd92d065..4c965aaf3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.addTo @@ -25,21 +24,23 @@ import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository import org.thoughtcrime.securesms.jobs.ForceUpdateGroupV2Job import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob -import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil import org.thoughtcrime.securesms.recipients.Recipient /** * Manages group state and actions for conversations. */ class ConversationGroupViewModel( - private val threadId: Long, private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(), private val recipientRepository: ConversationRecipientRepository ) : ViewModel() { private val disposables = CompositeDisposable() - private val _groupRecord: BehaviorSubject - private val _reviewState: Subject + + private val _groupRecord: BehaviorSubject = recipientRepository + .groupRecord + .filter { it.isPresent } + .map { it.get() } + .subscribeWithSubject(BehaviorSubject.create(), disposables) private val _groupActiveState: Subject = BehaviorSubject.create() private val _memberLevel: BehaviorSubject = BehaviorSubject.create() @@ -50,28 +51,6 @@ class ConversationGroupViewModel( get() = _groupRecord.value init { - _groupRecord = recipientRepository - .groupRecord - .filter { it.isPresent } - .map { it.get() } - .subscribeWithSubject(BehaviorSubject.create(), disposables) - - val duplicates = _groupRecord.map { groupRecord -> - if (groupRecord.isV2Group) { - ReviewUtil.getDuplicatedRecipients(groupRecord.id.requireV2()).map { it.recipient } - } else { - emptyList() - } - } - - _reviewState = Observable.combineLatest(_groupRecord, duplicates) { record, dupes -> - if (dupes.isEmpty()) { - ConversationGroupReviewState.EMPTY - } else { - ConversationGroupReviewState(record.id.requireV2(), dupes[0], dupes.size) - } - }.subscribeWithSubject(BehaviorSubject.create(), disposables) - disposables += _groupRecord.subscribe { groupRecord -> _groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.isV2Group)) _memberLevel.onNext(ConversationGroupMemberLevel(groupRecord.memberLevel(Recipient.self()), groupRecord.isAnnouncementGroup)) @@ -154,9 +133,9 @@ class ConversationGroupViewModel( .addTo(disposables) } - class Factory(private val threadId: Long, private val recipientRepository: ConversationRecipientRepository) : ViewModelProvider.Factory { + class Factory(private val recipientRepository: ConversationRecipientRepository) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(ConversationGroupViewModel(threadId, recipientRepository = recipientRepository)) as T + return modelClass.cast(ConversationGroupViewModel(recipientRepository = recipientRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 7f938d6224..5164e32712 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -813,6 +813,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT Recipient.live(groupRecipientId).refresh() notifyConversationListListeners() + if (groupId.isV2) { + SignalDatabase.nameCollisions.handleGroupNameCollisions(groupId.requireV2(), members.toSet()) + } + return true } @@ -881,7 +885,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT val groupMembers = getV2GroupMembers(decryptedGroup, true) - if (existingGroup.isPresent && existingGroup.get().isV2Group) { + val addedMembers: List = if (existingGroup.isPresent && existingGroup.get().isV2Group) { val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) val removed: List = DecryptedGroupUtil.removedMembersServiceIdList(change) @@ -898,6 +902,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT e164 = null ) } + + change.newMembers.toAciList().toRecipientIds() + } else { + groupMembers } writableDatabase.withinTransaction { database -> @@ -920,6 +928,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT Recipient.live(groupRecipientId).refresh() notifyConversationListListeners() + + if (groupId.isV2 && addedMembers.isNotEmpty()) { + SignalDatabase.nameCollisions.handleGroupNameCollisions(groupId.requireV2(), addedMembers.toSet()) + } } fun updateTitle(groupId: GroupId.V1, title: String?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 485e488a6a..e856df7c80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -1073,6 +1073,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat notifyConversationListeners(threadId) TrimThreadJob.enqueueAsync(threadId) } + + groupRecords.filter { it.isV2Group }.forEach { + SignalDatabase.nameCollisions.handleGroupNameCollisions(it.id.requireV2(), setOf(recipient.id)) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt new file mode 100644 index 0000000000..4430eae814 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt @@ -0,0 +1,475 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.core.content.contentValuesOf +import net.zetetic.database.sqlcipher.SQLiteDatabase +import org.signal.core.util.Base64 +import org.signal.core.util.Hex +import org.signal.core.util.SqlUtil +import org.signal.core.util.delete +import org.signal.core.util.exists +import org.signal.core.util.insertInto +import org.signal.core.util.orNull +import org.signal.core.util.readToList +import org.signal.core.util.readToSet +import org.signal.core.util.readToSingleLong +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.toInt +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupId.V2 +import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import java.io.IOException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import kotlin.time.Duration.Companion.days + +/** + * Tables to help manage the state of name collisions. + */ +class NameCollisionTables( + context: Context, + database: SignalDatabase +) : DatabaseTable(context, database) { + + companion object { + private const val ID = "_id" + + private val PROFILE_CHANGE_TIMEOUT = 1.days + + fun createTables(db: SQLiteDatabase) { + db.execSQL(NameCollisionTable.CREATE_TABLE) + db.execSQL(NameCollisionMembershipTable.CREATE_TABLE) + } + + fun createIndexes(db: SQLiteDatabase) { + NameCollisionMembershipTable.CREATE_INDEXES.forEach { + db.execSQL(it) + } + } + } + + /** + * Represents a detected name collision which can involve one or more recipients. + */ + private object NameCollisionTable { + const val TABLE_NAME = "name_collision" + + /** + * The thread id of the conversation to display this collision for. + */ + const val THREAD_ID = "thread_id" + + /** + * Whether the user has manually dismissed the collision. + */ + const val DISMISSED = "dismissed" + + /** + * The hash representing the latest known display name state. + */ + const val HASH = "hash" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $THREAD_ID INTEGER UNIQUE NOT NULL, + $DISMISSED INTEGER DEFAULT 0, + $HASH STRING DEFAULT NULL + ) + """ + } + + /** + * Represents a recipient who is involved in a name collision. + */ + private object NameCollisionMembershipTable { + const val TABLE_NAME = "name_collision_membership" + + /** + * FK Reference to a name_collision + */ + const val COLLISION_ID = "collision_id" + + /** + * FK Reference to the recipient involved + */ + const val RECIPIENT_ID = "recipient_id" + + /** + * Proto containing group profile change details. Only present for entries tied to group collisions. + */ + const val PROFILE_CHANGE_DETAILS = "profile_change_details" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLLISION_ID INTEGER NOT NULL REFERENCES ${NameCollisionTable.TABLE_NAME} ($ID) ON DELETE CASCADE, + $RECIPIENT_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} ($ID) ON DELETE CASCADE, + $PROFILE_CHANGE_DETAILS BLOB DEFAULT NULL, + UNIQUE ($COLLISION_ID, $RECIPIENT_ID) + ) + """ + + val CREATE_INDEXES = arrayOf( + "CREATE INDEX name_collision_membership_collision_id_index ON $TABLE_NAME ($COLLISION_ID)", + "CREATE INDEX name_collision_membership_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID)" + ) + } + + /** + * Marks the relevant collisions dismissed according to the given thread recipient. + */ + @WorkerThread + fun markCollisionsForThreadRecipientDismissed(threadRecipientId: RecipientId) { + writableDatabase.withinTransaction { db -> + val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId) ?: return@withinTransaction + + db.update(NameCollisionTable.TABLE_NAME) + .values(NameCollisionTable.DISMISSED to 1) + .where("${NameCollisionTable.THREAD_ID} = ?", threadId) + .run() + } + } + + /** + * @return A flattened list of similar recipients. + */ + @WorkerThread + fun getCollisionsForThreadRecipientId(recipientId: RecipientId): List { + val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return emptyList() + val collisionId = readableDatabase + .select(ID) + .from(NameCollisionTable.TABLE_NAME) + .where("${NameCollisionTable.THREAD_ID} = ? AND ${NameCollisionTable.DISMISSED} = 0", threadId) + .run() + .readToSingleLong() + + if (collisionId <= 0) { + return emptyList() + } + + val collisions = readableDatabase + .select() + .from(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.COLLISION_ID} = ?", collisionId) + .run() + .readToList { cursor -> + ReviewRecipient( + Recipient.resolved(RecipientId.from(cursor.requireLong(NameCollisionMembershipTable.RECIPIENT_ID))), + cursor.requireBlob(NameCollisionMembershipTable.PROFILE_CHANGE_DETAILS)?.let { ProfileChangeDetails.ADAPTER.decode(it) } + ) + }.toMutableList() + + val groups = collisions.groupBy { SqlUtil.buildCaseInsensitiveGlobPattern(it.recipient.getDisplayName(context)) } + val toDelete: List = groups.values.filter { it.size < 2 }.flatten() + val toReturn: List = groups.values.filter { it.size >= 2 }.flatten() + + if (toDelete.isNotEmpty()) { + writableDatabase.withinTransaction { db -> + val queries = SqlUtil.buildCollectionQuery( + column = NameCollisionMembershipTable.RECIPIENT_ID, + values = toDelete.map { it.recipient.id } + ) + + for (query in queries) { + db.delete(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.COLLISION_ID} = ? AND ${query.where}", SqlUtil.appendArgs(arrayOf(collisionId.toString()), query.whereArgs)) + .run() + } + + pruneCollisions() + } + } + + return toReturn + } + + /** + * Update the collision *only* for the given individual. + */ + @WorkerThread + fun handleIndividualNameCollision(recipientId: RecipientId) { + writableDatabase.withinTransaction { db -> + val similarRecipients = SignalDatabase.recipients.getSimilarRecipientIds(Recipient.resolved(recipientId)) + + db.delete(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.RECIPIENT_ID} = ?", recipientId) + .run() + + if (similarRecipients.size == 1) { + val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: -1 + if (threadId > 0L) { + db.delete(NameCollisionTable.TABLE_NAME) + .where("${NameCollisionTable.THREAD_ID} = ?", threadId) + .run() + } + } + + similarRecipients.forEach { threadRecipientId -> + handleNameCollisions( + threadRecipientId = threadRecipientId, + getCollisionRecipients = { + val recipients = Recipient.resolvedList(similarRecipients) + + recipients.map { ReviewRecipient(it) }.toSet() + } + ) + } + + pruneCollisions() + } + } + + /** + * Update the collisions for the given group + */ + @WorkerThread + fun handleGroupNameCollisions(groupId: GroupId.V2, changed: Set) { + writableDatabase.withinTransaction { + val threadRecipientId = SignalDatabase.recipients.getByGroupId(groupId).orNull() ?: return@withinTransaction + handleNameCollisions( + threadRecipientId = threadRecipientId, + getCollisionRecipients = { getDuplicatedGroupRecipients(groupId, changed).toSet() } + ) + + pruneCollisions() + } + } + + private fun handleNameCollisions( + threadRecipientId: RecipientId, + getCollisionRecipients: () -> Set + ) { + check(writableDatabase.inTransaction()) + + val resolved = Recipient.resolved(threadRecipientId) + val collisionRecipients: Set = getCollisionRecipients() + + if (collisionRecipients.size < 2 && !collisionExists(threadRecipientId)) { + return + } + + val collision: NameCollision = getOrCreateCollision(resolved) + val hash: String = calculateHash(collisionRecipients) + + updateCollision( + collision.copy( + members = collisionRecipients, + hash = hash, + dismissed = if (!collision.dismissed) false else collision.hash == hash + ) + ) + } + + private fun collisionExists(threadRecipientId: RecipientId): Boolean { + val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId) ?: return false + return writableDatabase + .exists(NameCollisionTable.TABLE_NAME) + .where("${NameCollisionTable.THREAD_ID} = ?", threadId) + .run() + } + + private fun getOrCreateCollision(threadRecipient: Recipient): NameCollision { + check(writableDatabase.inTransaction()) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient) + + val collision = writableDatabase + .select() + .from(NameCollisionTable.TABLE_NAME) + .where("${NameCollisionTable.THREAD_ID} = ?", threadId) + .run() + .readToSingleObject { nameCollisionCursor -> + NameCollision( + id = nameCollisionCursor.requireLong(ID), + threadId = threadId, + members = writableDatabase + .select(NameCollisionMembershipTable.RECIPIENT_ID, NameCollisionMembershipTable.PROFILE_CHANGE_DETAILS) + .from(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.COLLISION_ID} = ?", nameCollisionCursor.requireInt(ID)) + .run() + .readToSet { + val id = RecipientId.from(it.requireLong(NameCollisionMembershipTable.RECIPIENT_ID)) + val rawProfileChangeDetails = it.requireBlob(NameCollisionMembershipTable.PROFILE_CHANGE_DETAILS) + val profileChangeDetails = if (rawProfileChangeDetails != null) { + ProfileChangeDetails.ADAPTER.decode(rawProfileChangeDetails) + } else { + null + } + + ReviewRecipient( + Recipient.resolved(id), + profileChangeDetails + ) + }, + dismissed = nameCollisionCursor.requireBoolean(NameCollisionTable.DISMISSED), + hash = nameCollisionCursor.requireString(NameCollisionTable.HASH) ?: "" + ) + } + + return if (collision == null) { + val rowId = writableDatabase + .insertInto(NameCollisionTable.TABLE_NAME) + .values( + contentValuesOf( + NameCollisionTable.THREAD_ID to threadId, + NameCollisionTable.DISMISSED to 0, + NameCollisionTable.HASH to null + ) + ) + .run() + + NameCollision(id = rowId, threadId = threadId, members = emptySet(), dismissed = false, hash = "") + } else { + collision + } + } + + private fun updateCollision(collision: NameCollision) { + check(writableDatabase.inTransaction()) + + writableDatabase + .update(NameCollisionTable.TABLE_NAME) + .values( + contentValuesOf( + NameCollisionTable.DISMISSED to collision.dismissed.toInt(), + NameCollisionTable.THREAD_ID to collision.threadId, + NameCollisionTable.HASH to collision.hash + ) + ) + .where("$ID = ?", collision.id) + .run() + + writableDatabase + .delete(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.COLLISION_ID} = ?") + .run() + + if (collision.members.size < 2) { + return + } + + collision.members.forEach { member -> + writableDatabase + .insertInto(NameCollisionMembershipTable.TABLE_NAME) + .values( + NameCollisionMembershipTable.RECIPIENT_ID to member.recipient.id.toLong(), + NameCollisionMembershipTable.COLLISION_ID to collision.id, + NameCollisionMembershipTable.PROFILE_CHANGE_DETAILS to member.profileChangeDetails?.encode() + ) + .run(conflictStrategy = org.thoughtcrime.securesms.database.SQLiteDatabase.CONFLICT_IGNORE) + } + } + + private fun calculateHash(collisionRecipients: Set): String { + if (collisionRecipients.isEmpty()) { + return "" + } + + return try { + val digest = MessageDigest.getInstance("MD5") + val names = collisionRecipients.map { it.recipient.getDisplayName(context) } + names.forEach { digest.update(it.encodeToByteArray()) } + Hex.toStringCondensed(digest.digest()) + } catch (e: NoSuchAlgorithmException) { + "" + } + } + + /** + * Remove any collision for which there is only a single member. + */ + private fun pruneCollisions() { + check(writableDatabase.inTransaction()) + + writableDatabase.execSQL( + """ + DELETE FROM ${NameCollisionTable.TABLE_NAME} + WHERE ${NameCollisionTable.TABLE_NAME}.$ID IN ( + SELECT ${NameCollisionMembershipTable.COLLISION_ID} + FROM ${NameCollisionMembershipTable.TABLE_NAME} + GROUP BY ${NameCollisionMembershipTable.COLLISION_ID} + HAVING COUNT($ID) < 2 + ) + """.trimIndent() + ) + } + + private fun getDuplicatedGroupRecipients(groupId: V2, toCheck: Set): List { + if (toCheck.isEmpty()) { + return emptyList() + } + + val profileChangeRecords: Map = getProfileChangeRecordsForGroup(groupId).associateBy { it.fromRecipient.id } + val members: MutableList = SignalDatabase.groups.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF).toMutableList() + val changed: List = Recipient.resolvedList(toCheck) + .map { recipient -> ReviewRecipient(recipient.resolve(), profileChangeRecords[recipient.id]?.let { getProfileChangeDetails(it) }) } + .filter { !it.recipient.isSystemContact && it.recipient.nickname.isEmpty } + + val results = mutableListOf() + + for (reviewRecipient in changed) { + if (results.contains(reviewRecipient)) { + continue + } + + members.remove(reviewRecipient.recipient) + + for (member in members) { + if (member.getDisplayName(context) == reviewRecipient.recipient.getDisplayName(context)) { + results.add(reviewRecipient) + results.add(ReviewRecipient(member)) + } + } + } + + return results + } + + private fun getProfileChangeRecordsForGroup(groupId: V2): List { + val groupRecipientId = SignalDatabase.recipients.getByGroupId(groupId).get() + val groupThreadId = SignalDatabase.threads.getThreadIdFor(groupRecipientId) + + return if (groupThreadId == null) { + emptyList() + } else { + SignalDatabase.messages.getProfileChangeDetailsRecords( + groupThreadId, + System.currentTimeMillis() - PROFILE_CHANGE_TIMEOUT.inWholeMilliseconds + ) + } + } + + private fun getProfileChangeDetails(record: MessageRecord): ProfileChangeDetails { + try { + return ProfileChangeDetails.ADAPTER.decode(Base64.decode(record.body)) + } catch (e: IOException) { + throw IllegalArgumentException(e) + } + } + + private data class NameCollision( + val id: Long, + val threadId: Long, + val members: Set, + val dismissed: Boolean, + val hash: String + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index db956aaa84..c3cfa5b00c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.storage.StorageRecordUpdate import org.thoughtcrime.securesms.storage.StorageSyncHelper @@ -1715,9 +1716,20 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } fun getSimilarRecipientIds(recipient: Recipient): List { - val projection = SqlUtil.buildArgs(ID, "COALESCE(NULLIF($SYSTEM_JOINED_NAME, ''), NULLIF($PROFILE_JOINED_NAME, '')) AS checked_name") - val where = "checked_name = ? AND $HIDDEN = ?" - val arguments = SqlUtil.buildArgs(recipient.profileName.toString(), 0) + if (!recipient.nickname.isEmpty || recipient.isSystemContact) { + return emptyList() + } + + val threadId = threads.getThreadIdFor(recipient.id) + val isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(threadId, recipient) + if (isMessageRequestAccepted) { + return emptyList() + } + + val glob = SqlUtil.buildCaseInsensitiveGlobPattern(recipient.profileName.toString()) + val projection = SqlUtil.buildArgs(ID, "COALESCE(NULLIF($NICKNAME_JOINED_NAME, ''), NULLIF($SYSTEM_JOINED_NAME, ''), NULLIF($PROFILE_JOINED_NAME, '')) AS checked_name") + val where = "checked_name GLOB ? AND $HIDDEN = ? AND $BLOCKED = ?" + val arguments = SqlUtil.buildArgs(glob, 0, 0) readableDatabase.query(TABLE_NAME, projection, where, arguments, null, null, null).use { cursor -> if (cursor == null || cursor.count == 0) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index e054825cd8..3fccabc29a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -73,6 +73,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val callTable: CallTable = CallTable(context, this) val kyberPreKeyTable: KyberPreKeyTable = KyberPreKeyTable(context, this) val callLinkTable: CallLinkTable = CallLinkTable(context, this) + val nameCollisionTables: NameCollisionTables = NameCollisionTables(context, this) override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) { db.setForeignKeyConstraintsEnabled(true) @@ -109,6 +110,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data db.execSQL(CallLinkTable.CREATE_TABLE) db.execSQL(CallTable.CREATE_TABLE) db.execSQL(KyberPreKeyTable.CREATE_TABLE) + NameCollisionTables.createTables(db) executeStatements(db, SearchTable.CREATE_TABLE) executeStatements(db, RemappedRecordTables.CREATE_TABLE) executeStatements(db, MessageSendLogTables.CREATE_TABLE) @@ -139,6 +141,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data executeStatements(db, SearchTable.CREATE_TRIGGERS) executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS) + NameCollisionTables.createIndexes(db) + DistributionListTables.insertInitialDistributionListAtCreationTime(db) if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) { @@ -526,5 +530,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data @get:JvmName("callLinks") val callLinks: CallLinkTable get() = instance!!.callLinkTable + + @get:JvmStatic + @get:JvmName("nameCollisions") + val nameCollisions: NameCollisionTables + get() = instance!!.nameCollisionTables } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 17dabc9d4d..4f8fbc0f93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V224_AddAttachmentA import org.thoughtcrime.securesms.database.helpers.migration.V225_AddLocalUserJoinedStateAndGroupCallActiveState import org.thoughtcrime.securesms.database.helpers.migration.V226_AddAttachmentMediaIdIndex import org.thoughtcrime.securesms.database.helpers.migration.V227_AddAttachmentArchiveTransferState +import org.thoughtcrime.securesms.database.helpers.migration.V228_AddNameCollisionTables /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -172,10 +173,11 @@ object SignalDatabaseMigrations { 224 to V224_AddAttachmentArchiveColumns, 225 to V225_AddLocalUserJoinedStateAndGroupCallActiveState, 226 to V226_AddAttachmentMediaIdIndex, - 227 to V227_AddAttachmentArchiveTransferState + 227 to V227_AddAttachmentArchiveTransferState, + 228 to V228_AddNameCollisionTables ) - const val DATABASE_VERSION = 227 + const val DATABASE_VERSION = 228 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V228_AddNameCollisionTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V228_AddNameCollisionTables.kt new file mode 100644 index 0000000000..c2bc02dc10 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V228_AddNameCollisionTables.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Adds the tables for managing name collisions + */ +object V228_AddNameCollisionTables : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + """ + CREATE TABLE name_collision ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER UNIQUE NOT NULL, + dismissed INTEGER DEFAULT 0, + hash STRING DEFAULT NULL + ) + """ + ) + + db.execSQL( + """ + CREATE TABLE name_collision_membership ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + collision_id INTEGER NOT NULL REFERENCES name_collision (_id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + profile_change_details BLOB DEFAULT NULL, + UNIQUE (collision_id, recipient_id) + ) + """ + ) + + db.execSQL("CREATE INDEX name_collision_membership_collision_id_index ON name_collision_membership (collision_id)") + db.execSQL("CREATE INDEX name_collision_membership_recipient_id_index ON name_collision_membership (recipient_id)") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt index 90a3277d43..0e5dfd0ce3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.kt @@ -399,6 +399,20 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val Log.i(TAG, "Name changed, but wasn't relevant to write an event. blocked: ${recipient.isBlocked}, group: ${recipient.isGroup}, self: ${recipient.isSelf}, firstSet: ${localDisplayName.isEmpty()}, displayChange: ${remoteDisplayName != localDisplayName}") } + if (recipient.isIndividual && + !recipient.isSystemContact && + !recipient.nickname.isEmpty && + !recipient.isProfileSharing && + !recipient.isBlocked && + !recipient.isSelf && + !recipient.isHidden + ) { + val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id) + if (threadId != null && !RecipientUtil.isMessageRequestAccepted(threadId, recipient)) { + SignalDatabase.nameCollisions.handleIndividualNameCollision(recipient.id) + } + } + if (writeChangeEvent || localDisplayName.isEmpty()) { ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() val threadId = SignalDatabase.threads.getThreadIdFor(recipient.id) diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java index 232a77795d..ee832801b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java @@ -51,7 +51,7 @@ class ReviewCardRepository { if (groupId != null) { loadRecipientsForGroup(groupId, onRecipientsLoadedListener); } else if (recipientId != null) { - loadSimilarRecipients(context, recipientId, onRecipientsLoadedListener); + loadSimilarRecipients(recipientId, onRecipientsLoadedListener); } else { throw new AssertionError(); } @@ -113,34 +113,21 @@ class ReviewCardRepository { private static void loadRecipientsForGroup(@NonNull GroupId.V2 groupId, @NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) { - SignalExecutors.BOUNDED.execute(() -> onRecipientsLoadedListener.onRecipientsLoaded(ReviewUtil.getDuplicatedRecipients(groupId))); + SignalExecutors.BOUNDED.execute(() -> { + RecipientId groupRecipientId = SignalDatabase.recipients().getByGroupId(groupId).orElse(null); + if (groupRecipientId != null) { + onRecipientsLoadedListener.onRecipientsLoaded(SignalDatabase.nameCollisions().getCollisionsForThreadRecipientId(groupRecipientId)); + } else { + onRecipientsLoadedListener.onRecipientsLoadFailed(); + } + }); } - private static void loadSimilarRecipients(@NonNull Context context, - @NonNull RecipientId recipientId, + private static void loadSimilarRecipients(@NonNull RecipientId recipientId, @NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) { SignalExecutors.BOUNDED.execute(() -> { - Recipient resolved = Recipient.resolved(recipientId); - - List recipientIds = SignalDatabase.recipients() - .getSimilarRecipientIds(resolved); - - if (recipientIds.isEmpty()) { - onRecipientsLoadedListener.onRecipientsLoadFailed(); - return; - } - - HashSet ids = new HashSet<>(recipientIds); - ids.add(recipientId); - - List recipients = Stream.of(ids) - .map(Recipient::resolved) - .map(ReviewRecipient::new) - .sorted(new ReviewRecipient.Comparator(context, recipientId)) - .toList(); - - onRecipientsLoadedListener.onRecipientsLoaded(recipients); + onRecipientsLoadedListener.onRecipientsLoaded(SignalDatabase.nameCollisions().getCollisionsForThreadRecipientId(recipientId)); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java index fc6df5ca79..2dcd8c6bce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java @@ -13,11 +13,11 @@ public class ReviewRecipient { private final Recipient recipient; private final ProfileChangeDetails profileChangeDetails; - ReviewRecipient(@NonNull Recipient recipient) { + public ReviewRecipient(@NonNull Recipient recipient) { this(recipient, null); } - ReviewRecipient(@NonNull Recipient recipient, @Nullable ProfileChangeDetails profileChangeDetails) { + public ReviewRecipient(@NonNull Recipient recipient, @Nullable ProfileChangeDetails profileChangeDetails) { this.recipient = recipient; this.profileChangeDetails = profileChangeDetails; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java index 173ea4b773..e4f098e468 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java @@ -7,102 +7,15 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.GroupRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.signal.core.util.Base64; - -import java.io.IOException; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.TimeUnit; public final class ReviewUtil { private ReviewUtil() { } - private static final long TIMEOUT = TimeUnit.HOURS.toMillis(24); - - /** - * Checks a single recipient against the database to see whether duplicates exist. - * This should not be used in the context of a group, due to performance reasons. - * - * @param recipientId Id of the recipient we are interested in. - * @return Whether or not multiple recipients share this profile name. - */ - @WorkerThread - public static List getRecipientsToPromptForReview(@NonNull RecipientId recipientId) - { - Recipient recipient = Recipient.resolved(recipientId); - - if (recipient.isGroup() || recipient.isSystemContact()) { - return Collections.emptyList(); - } - - return Stream.of(SignalDatabase.recipients().getSimilarRecipientIds(recipient)) - .filter(id -> !id.equals(recipientId)) - .toList(); - } - - @WorkerThread - public static @NonNull List getDuplicatedRecipients(@NonNull GroupId.V2 groupId) - { - Context context = ApplicationDependencies.getApplication(); - List profileChangeRecords = getProfileChangeRecordsForGroup(context, groupId); - - if (profileChangeRecords.isEmpty()) { - return Collections.emptyList(); - } - - List members = SignalDatabase.groups() - .getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF); - - List changed = Stream.of(profileChangeRecords) - .distinctBy(record -> record.getFromRecipient().getId()) - .map(record -> new ReviewRecipient(record.getFromRecipient().resolve(), getProfileChangeDetails(record))) - .filter(recipient -> !recipient.getRecipient().isSystemContact()) - .toList(); - - List results = new LinkedList<>(); - - for (ReviewRecipient recipient : changed) { - if (results.contains(recipient)) { - continue; - } - - members.remove(recipient.getRecipient()); - - for (Recipient member : members) { - if (Objects.equals(member.getDisplayName(context), recipient.getRecipient().getDisplayName(context))) { - results.add(recipient); - results.add(new ReviewRecipient(member)); - } - } - } - - return results; - } - - @WorkerThread - public static @NonNull List getProfileChangeRecordsForGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) { - RecipientId recipientId = SignalDatabase.recipients().getByGroupId(groupId).get(); - Long threadId = SignalDatabase.threads().getThreadIdFor(recipientId); - - if (threadId == null) { - return Collections.emptyList(); - } else { - return SignalDatabase.messages().getProfileChangeDetailsRecords(threadId, System.currentTimeMillis() - TIMEOUT); - } - } - @WorkerThread public static int getGroupsInCommonCount(@NonNull Context context, @NonNull RecipientId recipientId) { return Stream.of(SignalDatabase.groups() @@ -112,12 +25,4 @@ public final class ReviewUtil { .toList() .size(); } - - private static @NonNull ProfileChangeDetails getProfileChangeDetails(@NonNull MessageRecord messageRecord) { - try { - return ProfileChangeDetails.ADAPTER.decode(Base64.decode(messageRecord.getBody())); - } catch (IOException e) { - throw new IllegalArgumentException(e); - } - } }