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

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