Add new name collision state management.
This commit is contained in:
parent
62cf3feeaa
commit
15d8a698c5
17 changed files with 861 additions and 164 deletions
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<IdentityRecord>)
|
||||
fun onRequestReviewIndividual(recipientId: RecipientId)
|
||||
fun onReviewGroupMembers(groupId: GroupId.V2)
|
||||
fun onDismissReview()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<RequestReviewState> {
|
||||
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<Recipient> = ReviewUtil.getDuplicatedRecipients(groupId).map { it.recipient }
|
||||
val duplicateRecipients: List<ReviewRecipient> = 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
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<GroupRecord>
|
||||
private val _reviewState: Subject<ConversationGroupReviewState>
|
||||
|
||||
private val _groupRecord: BehaviorSubject<GroupRecord> = recipientRepository
|
||||
.groupRecord
|
||||
.filter { it.isPresent }
|
||||
.map { it.get() }
|
||||
.subscribeWithSubject(BehaviorSubject.create(), disposables)
|
||||
|
||||
private val _groupActiveState: Subject<ConversationGroupActiveState> = BehaviorSubject.create()
|
||||
private val _memberLevel: BehaviorSubject<ConversationGroupMemberLevel> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ConversationGroupViewModel(threadId, recipientRepository = recipientRepository)) as T
|
||||
return modelClass.cast(ConversationGroupViewModel(recipientRepository = recipientRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<RecipientId> = if (existingGroup.isPresent && existingGroup.get().isV2Group) {
|
||||
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
|
||||
val removed: List<ServiceId> = 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?) {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<ReviewRecipient> {
|
||||
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<ReviewRecipient> = groups.values.filter { it.size < 2 }.flatten()
|
||||
val toReturn: List<ReviewRecipient> = 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<RecipientId>) {
|
||||
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<ReviewRecipient>
|
||||
) {
|
||||
check(writableDatabase.inTransaction())
|
||||
|
||||
val resolved = Recipient.resolved(threadRecipientId)
|
||||
val collisionRecipients: Set<ReviewRecipient> = 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<ReviewRecipient>): 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<RecipientId>): List<ReviewRecipient> {
|
||||
if (toCheck.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val profileChangeRecords: Map<RecipientId, MessageRecord> = getProfileChangeRecordsForGroup(groupId).associateBy { it.fromRecipient.id }
|
||||
val members: MutableList<Recipient> = SignalDatabase.groups.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF).toMutableList()
|
||||
val changed: List<ReviewRecipient> = 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<ReviewRecipient>()
|
||||
|
||||
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<MessageRecord> {
|
||||
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<ReviewRecipient>,
|
||||
val dismissed: Boolean,
|
||||
val hash: String
|
||||
)
|
||||
}
|
|
@ -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<RecipientId> {
|
||||
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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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<RecipientId> recipientIds = SignalDatabase.recipients()
|
||||
.getSimilarRecipientIds(resolved);
|
||||
|
||||
if (recipientIds.isEmpty()) {
|
||||
onRecipientsLoadedListener.onRecipientsLoadFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<RecipientId> ids = new HashSet<>(recipientIds);
|
||||
ids.add(recipientId);
|
||||
|
||||
List<ReviewRecipient> 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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<RecipientId> 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<ReviewRecipient> getDuplicatedRecipients(@NonNull GroupId.V2 groupId)
|
||||
{
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
List<MessageRecord> profileChangeRecords = getProfileChangeRecordsForGroup(context, groupId);
|
||||
|
||||
if (profileChangeRecords.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Recipient> members = SignalDatabase.groups()
|
||||
.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF);
|
||||
|
||||
List<ReviewRecipient> 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<ReviewRecipient> 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<MessageRecord> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue