Add new name collision state management.

This commit is contained in:
Alex Hart 2024-04-18 15:53:39 -03:00 committed by Greyson Parrelli
parent 62cf3feeaa
commit 15d8a698c5
17 changed files with 861 additions and 164 deletions

View file

@ -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
)
)
}
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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
)
)

View file

@ -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()

View file

@ -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
}
}
}

View file

@ -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?) {

View file

@ -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))
}
}
}

View file

@ -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
)
}

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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)")
}
}

View file

@ -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)

View file

@ -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));
});
}

View file

@ -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;
}

View file

@ -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);
}
}
}