Introduce ManyToMany table for group membership.

This commit is contained in:
Alex Hart 2023-01-24 09:59:01 -04:00 committed by Greyson Parrelli
parent d635683303
commit 1b7e4e047c
11 changed files with 486 additions and 165 deletions

View File

@ -48,8 +48,6 @@
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>

View File

@ -64,7 +64,7 @@ class SafetyNumberChangeDialogPreviewer {
SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations(
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId))
destinations = listOf(ContactSearchKey.RecipientSearchKey(myStoryRecipientId, true))
)
.show(conversationActivity.supportFragmentManager)
}

View File

@ -0,0 +1,213 @@
package org.thoughtcrime.securesms.database
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.delete
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.security.SecureRandom
import kotlin.random.Random
class GroupTableTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var groupTable: GroupTable
@Before
fun setUp() {
groupTable = SignalDatabase.groups
groupTable.writableDatabase.delete(GroupTable.TABLE_NAME).run()
groupTable.writableDatabase.delete(GroupTable.MembershipTable.TABLE_NAME).run()
}
@Test
fun whenICreateGroupV2_thenIExpectMemberRowsPopulated() {
val groupId = insertPushGroup()
//language=sql
val members: List<RecipientId> = groupTable.writableDatabase.query(
"""
SELECT ${GroupTable.MembershipTable.RECIPIENT_ID}
FROM ${GroupTable.MembershipTable.TABLE_NAME}
WHERE ${GroupTable.MembershipTable.GROUP_ID} = "${groupId.serialize()}"
""".trimIndent()
).readToList {
RecipientId.from(it.requireLong(GroupTable.RECIPIENT_ID))
}
assertEquals(2, members.size)
}
@Test
fun givenAGroupV2_whenIGetGroupsContainingMember_thenIExpectGroup() {
val groupId = insertPushGroup()
insertThread(groupId)
val groups = groupTable.getGroupsContainingMember(harness.others[0], false)
assertEquals(1, groups.size)
assertEquals(groupId, groups[0].id)
}
@Test
fun givenAnMmsGroup_whenIGetMembers_thenIExpectAllMembers() {
val groupId = insertMmsGroup()
val groups = groupTable.getGroupMemberIds(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
assertEquals(2, groups.size)
}
@Test
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.queryGroupsByMembership(
setOf(harness.self.id, harness.others[1]),
includeInactive = false,
excludeV1 = false,
excludeMms = false
)
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.getGroups()
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenAGroup_whenIGetGroup_thenIExpectGroup() {
val v2Group = insertPushGroup()
insertThread(v2Group)
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, harness.others[0]), groupRecord.members.toSet())
}
@Test
fun givenAGroupAndARemap_whenIGetGroup_thenIExpectRemap() {
val v2Group = insertPushGroup()
insertThread(v2Group)
groupTable.writableDatabase.withinTransaction {
RemappedRecords.getInstance().addRecipient(harness.others[0], harness.others[1])
}
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(groupRecord.members.toSet(), setOf(harness.self.id, harness.others[1]))
}
@Test
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertTrue(actual)
}
@Test
fun givenAGroupAndMember_whenIRemove_thenIExpectNotAMember() {
val v2Group = insertPushGroup()
groupTable.remove(v2Group, harness.others[0])
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
assertFalse(actual)
}
@Test
fun givenAGroupAndNonMember_whenIIsCurrentMember_thenIExpectFalse() {
val v2Group = insertPushGroup()
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[1])
assertFalse(actual)
}
@Test
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
val v2Group = insertPushGroup()
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
val groupRecord = groupTable.getGroup(v2Group)
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
}
@Test
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
val other = insertMmsGroup(members + listOf(harness.others[1]))
val mmsGroup = insertMmsGroup(members)
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
assertNotEquals(other, actual)
assertEquals(mmsGroup, actual)
}
private fun insertThread(groupId: GroupId): Long {
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
}
private fun insertMmsGroup(members: List<RecipientId> = listOf(harness.self.id, harness.others[0])): GroupId {
val id = GroupId.createMms(SecureRandom())
groupTable.create(
id,
null,
members.apply {
println("Creating a group with ${members.size} members")
}
)
return id
}
private fun insertPushGroup(): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(
listOf(
DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
)
)
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)
}
}

View File

@ -27,7 +27,7 @@ class SafetyNumberBottomSheetRepositoryTest {
@Test
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
val recipients = harness.others
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey(it, false) }
val result = subjectUnderTest.getBuckets(recipients, destinations).test()
@ -42,7 +42,7 @@ class SafetyNumberBottomSheetRepositoryTest {
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
// GIVEN
val recipients = harness.others
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey(it, false) }
// WHEN
val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
@ -59,7 +59,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
// WHEN
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
@ -82,7 +82,7 @@ class SafetyNumberBottomSheetRepositoryTest {
val distributionListMembers = harness.others.take(5)
val toRemove = distributionListMembers.last()
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()
@ -108,7 +108,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN
val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions()

View File

@ -13,6 +13,7 @@ import org.signal.core.util.SqlUtil.appendArg
import org.signal.core.util.SqlUtil.buildArgs
import org.signal.core.util.SqlUtil.buildCaseInsensitiveGlobPattern
import org.signal.core.util.SqlUtil.buildCollectionQuery
import org.signal.core.util.delete
import org.signal.core.util.exists
import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log
@ -50,7 +51,6 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
@ -58,11 +58,7 @@ import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.io.Closeable
import java.lang.AssertionError
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
import java.security.SecureRandom
import java.util.ArrayList
import java.util.Optional
import java.util.UUID
import java.util.stream.Collectors
@ -72,12 +68,13 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
companion object {
private val TAG = Log.tag(GroupTable::class.java)
const val MEMBER_GROUP_CONCAT = "member_group_concat"
const val TABLE_NAME = "groups"
const val ID = "_id"
const val GROUP_ID = "group_id"
const val RECIPIENT_ID = "recipient_id"
const val TITLE = "title"
const val MEMBERS = "members"
const val AVATAR_ID = "avatar_id"
const val AVATAR_KEY = "avatar_key"
const val AVATAR_CONTENT_TYPE = "avatar_content_type"
@ -112,7 +109,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
$GROUP_ID TEXT,
$RECIPIENT_ID INTEGER,
$TITLE TEXT,
$MEMBERS TEXT,
$AVATAR_ID INTEGER,
$AVATAR_KEY BLOB,
$AVATAR_CONTENT_TYPE TEXT,
@ -145,7 +141,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
GROUP_ID,
RECIPIENT_ID,
TITLE,
MEMBERS,
UNMIGRATED_V1_MEMBERS,
AVATAR_ID,
AVATAR_KEY,
@ -165,43 +160,77 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
.filterNot { it == RECIPIENT_ID }
.map { columnName: String -> "$TABLE_NAME.$columnName" }
.toList()
//language=sql
private val JOINED_GROUP_SELECT = """
SELECT
DISTINCT $TABLE_NAME.*,
GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) as $MEMBER_GROUP_CONCAT
FROM $TABLE_NAME
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
""".toSingleLine()
val CREATE_TABLES = arrayOf(CREATE_TABLE, MembershipTable.CREATE_TABLE)
}
class MembershipTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
companion object {
const val TABLE_NAME = "group_membership"
const val ID = "_id"
const val GROUP_ID = "group_id"
const val RECIPIENT_ID = "recipient_id"
//language=sql
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$GROUP_ID TEXT NOT NULL,
$RECIPIENT_ID INTEGER NOT NULL,
UNIQUE($GROUP_ID, $RECIPIENT_ID)
)
""".toSingleLine()
}
}
fun getGroup(recipientId: RecipientId): Optional<GroupRecord> {
readableDatabase
.select()
.from(TABLE_NAME)
.where("$RECIPIENT_ID = ?", recipientId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
return getGroup(SqlUtil.Query("$TABLE_NAME.$RECIPIENT_ID = ?", buildArgs(recipientId)))
}
fun getGroup(groupId: GroupId): Optional<GroupRecord> {
return getGroup(SqlUtil.Query("$TABLE_NAME.$GROUP_ID = ?", buildArgs(groupId)))
}
private fun getGroup(query: SqlUtil.Query): Optional<GroupRecord> {
//language=sql
val select = "$JOINED_GROUP_SELECT WHERE ${query.where} GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}"
readableDatabase
.select()
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString())
.run()
.query(select, query.whereArgs)
.use { cursor ->
return if (cursor.moveToFirst()) {
val groupRecord = getGroup(cursor)
if (groupRecord.isPresent && RemappedRecords.getInstance().areAnyRemapped(groupRecord.get().members)) {
val groupId = groupRecord.get().id
val remaps = RemappedRecords.getInstance().buildRemapDescription(groupRecord.get().members)
Log.w(TAG, "Found a group with remapped recipients in it's membership list! Updating the list. GroupId: $groupId, Remaps: $remaps", true)
val remapped: Collection<RecipientId> = RemappedRecords.getInstance().remap(groupRecord.get().members)
val oldToNew: List<Pair<RecipientId, RecipientId?>> = groupRecord.get().members.map {
it to RemappedRecords.getInstance().getRecipient(it).orElse(null)
}.filterNot { (old, new) -> new == null || old == new }
val updateCount = writableDatabase
.update(TABLE_NAME)
.values(MEMBERS to remapped.serialize())
.where("$GROUP_ID = ?", groupId)
.run()
var updateCount = 0
if (oldToNew.isNotEmpty()) {
writableDatabase.withinTransaction { db ->
for ((old, new) in oldToNew) {
updateCount += db.update(MembershipTable.TABLE_NAME)
.values(MembershipTable.RECIPIENT_ID to new!!.serialize())
.where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, old)
.run()
}
}
}
if (updateCount > 0) {
getGroup(groupId)
@ -240,33 +269,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
* @return A gv1 group whose expected v2 ID matches the one provided.
*/
fun getGroupV1ByExpectedV2(gv2Id: GroupId.V2): Optional<GroupRecord> {
readableDatabase
.select(*GROUP_PROJECTION)
.from(TABLE_NAME)
.where("$EXPECTED_V2_ID = ?", gv2Id)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
return getGroup(SqlUtil.Query("$TABLE_NAME.$EXPECTED_V2_ID = ?", buildArgs(gv2Id)))
}
fun getGroupByDistributionId(distributionId: DistributionId): Optional<GroupRecord> {
readableDatabase
.select()
.from(TABLE_NAME)
.where("$DISTRIBUTION_ID = ?", distributionId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
return getGroup(SqlUtil.Query("$TABLE_NAME.$DISTRIBUTION_ID = ?", buildArgs(distributionId)))
}
fun removeUnmigratedV1Members(id: GroupId.V2) {
@ -338,7 +345,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms)
val cursor = databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, "$TITLE COLLATE NOCASE ASC")
val statement = """
$JOINED_GROUP_SELECT
WHERE ${query.where}
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
""".trimIndent()
val cursor = databaseHelper.signalReadableDatabase.query(statement, query.whereArgs)
return Reader(cursor)
}
@ -353,21 +367,17 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
recipientIds = recipientIds.take(30).toSet()
}
val recipientLikeClauses = recipientIds
.map { it.toLong() }
.map { id -> "($MEMBERS LIKE $id || ',%' OR $MEMBERS LIKE '%,' || $id || ',%' OR $MEMBERS LIKE '%,' || $id)" }
.toList()
val membershipQuery = SqlUtil.buildSingleCollectionQuery("${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}", recipientIds)
var query: String
val queryArgs: Array<String>
val membershipQuery = "(" + Util.join(recipientLikeClauses, " OR ") + ")"
if (includeInactive) {
query = "$membershipQuery AND ($ACTIVE = ? OR $RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))"
queryArgs = buildArgs(1)
query = "${membershipQuery.where} AND ($ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))"
queryArgs = membershipQuery.whereArgs + buildArgs(1)
} else {
query = "$membershipQuery AND $ACTIVE = ?"
queryArgs = buildArgs(1)
query = "${membershipQuery.where} AND $ACTIVE = ?"
queryArgs = membershipQuery.whereArgs + buildArgs(1)
}
if (excludeV1) {
@ -378,15 +388,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
query += " AND $MMS = 0"
}
return Reader(readableDatabase.query(TABLE_NAME, null, query, queryArgs, null, null, null))
return Reader(readableDatabase.query("$JOINED_GROUP_SELECT WHERE $query GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}", queryArgs))
}
private fun queryGroupsByRecency(groupQuery: GroupQuery): Reader {
val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms)
val sql = """
SELECT $TABLE_NAME.*
FROM $TABLE_NAME LEFT JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}
$JOINED_GROUP_SELECT
WHERE ${query.where}
${"GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}"}
ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC
""".toSingleLine()
@ -407,7 +417,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery)
if (includeInactive) {
query = "$TITLE GLOB ? AND ($ACTIVE = ? OR $RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))"
query = "$TITLE GLOB ? AND ($ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))"
queryArgs = buildArgs(caseInsensitiveQuery, 1)
} else {
query = "$TITLE GLOB ? AND $ACTIVE = ?"
@ -456,23 +466,27 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
}
fun getOrCreateMmsGroupForMembers(members: List<RecipientId>): GroupId.Mms {
val sortedMembers = members.sorted()
fun getOrCreateMmsGroupForMembers(members: Set<RecipientId>): GroupId.Mms {
//language=sql
val statement = """
SELECT ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} as gid
FROM ${MembershipTable.TABLE_NAME}
INNER JOIN $TABLE_NAME ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
WHERE ${MembershipTable.TABLE_NAME}.$RECIPIENT_ID IN (${members.joinToString(",") { it.serialize() }}) AND $TABLE_NAME.$MMS = 1
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
HAVING (SELECT COUNT(*) FROM ${MembershipTable.TABLE_NAME} WHERE ${MembershipTable.GROUP_ID} = gid) = ${members.size}
ORDER BY ${MembershipTable.TABLE_NAME}.${MembershipTable.ID} ASC
""".toSingleLine()
readableDatabase
.select(GROUP_ID)
.from(TABLE_NAME)
.where("$MEMBERS = ? AND $MMS = ?", sortedMembers.serialize(), 1)
.run()
.use { cursor ->
return if (cursor.moveToNext()) {
GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)).requireMms()
} else {
val groupId = GroupId.createMms(SecureRandom())
create(groupId, null, sortedMembers)
groupId
}
return readableDatabase.query(statement).use { cursor ->
if (cursor.moveToNext()) {
return GroupId.parseOrThrow(cursor.requireNonNullString("gid")).requireMms()
} else {
val groupId = GroupId.createMms(SecureRandom())
create(groupId, null, members)
groupId
}
}
}
@WorkerThread
@ -493,9 +507,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
@WorkerThread
fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean, includeInactive: Boolean): List<GroupRecord> {
val table = "$TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}"
var query = "$MEMBERS LIKE ?"
var args = buildArgs("%${recipientId.serialize()}%")
//language=sql
val table = """
$JOINED_GROUP_SELECT
INNER JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}
""".toSingleLine()
var query = "${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} = ?"
var args = buildArgs(recipientId)
val orderBy = "${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC"
if (pushOnly) {
@ -509,23 +528,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
return readableDatabase
.query(table, null, query, args, null, null, orderBy)
.query("$table WHERE $query GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} ORDER BY $orderBy".apply { println(this) }, args)
.readToList { cursor ->
val serializedMembers = cursor.requireNonNullString(MEMBERS)
if (RecipientId.serializedListContains(serializedMembers, recipientId)) {
getGroup(cursor).get()
} else {
null
}
getGroup(cursor).get()
}
.filterNotNull()
}
fun getGroups(): Reader {
val cursor = readableDatabase
.select()
.from(TABLE_NAME)
.run()
val cursor = readableDatabase.query("$JOINED_GROUP_SELECT GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}")
return Reader(cursor)
}
@ -648,6 +658,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
groupMasterKey: GroupMasterKey?,
groupState: DecryptedGroup?
) {
val membershipValues = mutableListOf<ContentValues>()
val groupRecipientId = recipients.getOrInsertFromGroupId(groupId)
val members: List<RecipientId> = memberCollection.toSet().sorted()
var groupMembers: List<RecipientId> = members
@ -657,7 +668,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
values.put(RECIPIENT_ID, groupRecipientId.serialize())
values.put(GROUP_ID, groupId.toString())
values.put(TITLE, title)
values.put(MEMBERS, members.serialize())
membershipValues.addAll(members.toContentValues(groupId))
values.put(MMS, groupId.isMms)
if (avatar != null) {
@ -693,14 +704,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
values.put(V2_MASTER_KEY, groupMasterKey.serialize())
values.put(V2_REVISION, groupState.revision)
values.put(V2_DECRYPTED_GROUP, groupState.toByteArray())
values.put(MEMBERS, groupMembers.serialize())
membershipValues.clear()
membershipValues.addAll(groupMembers.toContentValues(groupId))
} else {
if (groupId.isV2) {
throw AssertionError("V2 group id but no master key")
}
}
writableDatabase.insert(TABLE_NAME, null, values)
writableDatabase.withinTransaction { db ->
db.insert(TABLE_NAME, null, values)
SqlUtil.buildBulkInsert(
MembershipTable.TABLE_NAME,
arrayOf(MembershipTable.GROUP_ID, MembershipTable.RECIPIENT_ID),
membershipValues
)
.forEach {
db.execSQL(it.where, it.whereArgs)
}
}
if (groupState != null && groupState.hasDisappearingMessagesTimer()) {
recipients.setExpireMessages(groupRecipientId, groupState.disappearingMessagesTimer.duration)
@ -829,7 +851,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
val groupMembers = getV2GroupMembers(decryptedGroup, true)
contentValues.put(MEMBERS, groupMembers.serialize())
if (existingGroup.isPresent && existingGroup.get().isV2Group) {
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
@ -842,11 +863,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
}
writableDatabase
.update(TABLE_NAME)
.values(contentValues)
.where("$GROUP_ID = ?", groupId.toString())
.run()
writableDatabase.withinTransaction { database ->
database
.update(TABLE_NAME)
.values(contentValues)
.where("$GROUP_ID = ?", groupId.toString())
.run()
performMembershipUpdate(database, groupId, groupMembers)
}
if (decryptedGroup.hasDisappearingMessagesTimer()) {
recipients.setExpireMessages(groupRecipientId, decryptedGroup.disappearingMessagesTimer.duration)
@ -898,27 +923,24 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
fun updateMembers(groupId: GroupId, members: List<RecipientId>) {
writableDatabase
.update(TABLE_NAME)
.values(
MEMBERS to members.sorted().serialize(),
ACTIVE to 1
)
.where("$GROUP_ID = ?", groupId)
.run()
writableDatabase.withinTransaction { database ->
database
.update(TABLE_NAME)
.values(ACTIVE to 1)
.where("$GROUP_ID = ?", groupId)
.run()
performMembershipUpdate(database, groupId, members)
}
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh()
}
fun remove(groupId: GroupId, source: RecipientId) {
val currentMembers: MutableList<RecipientId> = getCurrentMembers(groupId)
currentMembers -= source
writableDatabase
.update(TABLE_NAME)
.values(MEMBERS to currentMembers.serialize())
.where("$GROUP_ID = ?", groupId)
.delete(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, source)
.run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
@ -927,17 +949,34 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
private fun getCurrentMembers(groupId: GroupId): MutableList<RecipientId> {
return readableDatabase
.select(MEMBERS)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId)
.select(MembershipTable.RECIPIENT_ID)
.from(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ?", groupId)
.run()
.readToList { cursor ->
val serializedMembers = cursor.requireNonNullString(MEMBERS)
return RecipientId.fromSerializedList(serializedMembers)
RecipientId.from(cursor.requireLong(MembershipTable.RECIPIENT_ID))
}
.toMutableList()
}
private fun performMembershipUpdate(database: SQLiteDatabase, groupId: GroupId, members: Collection<RecipientId>) {
check(database.inTransaction())
database
.delete(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ?", groupId)
.run()
val inserts = SqlUtil.buildBulkInsert(
MembershipTable.TABLE_NAME,
arrayOf(MembershipTable.GROUP_ID, MembershipTable.RECIPIENT_ID),
members.toContentValues(groupId)
)
inserts.forEach {
database.execSQL(it.where, it.whereArgs)
}
}
fun isActive(groupId: GroupId): Boolean {
val record = getGroup(groupId)
return record.isPresent && record.get().isActive
@ -961,19 +1000,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
@WorkerThread
fun isCurrentMember(groupId: Push, recipientId: RecipientId): Boolean {
readableDatabase
.select(MEMBERS)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId)
return readableDatabase
.exists(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, recipientId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
val serializedMembers = cursor.requireNonNullString(MEMBERS)
RecipientId.serializedListContains(serializedMembers, recipientId)
} else {
false
}
}
}
fun getAllGroupV2Ids(): List<GroupId.V2> {
@ -1005,15 +1035,13 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
writableDatabase
.update(MembershipTable.TABLE_NAME)
.values(RECIPIENT_ID to toId.serialize())
.where("${MembershipTable.RECIPIENT_ID} = ?", fromId)
.run()
for (group in getGroupsContainingMember(fromId, false, true)) {
val newMembers: Set<RecipientId> = group.members.toSet() - fromId + toId
writableDatabase
.update(TABLE_NAME)
.values(MEMBERS to newMembers.serialize())
.where("$RECIPIENT_ID = ?", group.recipientId)
.run()
if (group.isV2Group) {
removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId))
}
@ -1042,7 +1070,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
id = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)),
recipientId = RecipientId.from(cursor.requireNonNullString(RECIPIENT_ID)),
title = cursor.requireString(TITLE),
serializedMembers = cursor.requireString(MEMBERS),
serializedMembers = cursor.requireString(MEMBER_GROUP_CONCAT),
serializedUnmigratedV1Members = cursor.requireString(UNMIGRATED_V1_MEMBERS),
avatarId = cursor.requireLong(AVATAR_ID),
avatarKey = cursor.requireBlob(AVATAR_KEY),
@ -1252,6 +1280,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
return RecipientId.toSerializedList(this)
}
private fun Collection<RecipientId>.toContentValues(groupId: GroupId): List<ContentValues> {
return map {
contentValuesOf(
MembershipTable.GROUP_ID to groupId.serialize(),
MembershipTable.RECIPIENT_ID to it.serialize()
)
}
}
private fun uuidsToRecipientIds(uuids: List<UUID>): MutableList<RecipientId> {
return uuids
.asSequence()

View File

@ -86,7 +86,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
db.execSQL(IdentityTable.CREATE_TABLE)
db.execSQL(DraftTable.CREATE_TABLE)
db.execSQL(PushTable.CREATE_TABLE)
db.execSQL(GroupTable.CREATE_TABLE)
executeStatements(db, GroupTable.CREATE_TABLES)
db.execSQL(RecipientTable.CREATE_TABLE)
db.execSQL(GroupReceiptTable.CREATE_TABLE)
db.execSQL(OneTimePreKeyTable.CREATE_TABLE)

View File

@ -1610,12 +1610,17 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
private fun createQuery(where: String, orderBy: String, offset: Long, limit: Long): String {
val projection = COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION.joinToString(separator = ",")
//language=sql
var query = """
SELECT $projection
SELECT $projection, ${GroupTable.MEMBER_GROUP_CONCAT}
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
LEFT OUTER JOIN ${GroupTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
WHERE $where
LEFT OUTER JOIN ${GroupTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
LEFT OUTER JOIN (
SELECT group_id, GROUP_CONCAT(${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID}) as ${GroupTable.MEMBER_GROUP_CONCAT}
FROM ${GroupTable.MembershipTable.TABLE_NAME}
) as MembershipAlias ON MembershipAlias.${GroupTable.MembershipTable.GROUP_ID} = ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID}
WHERE $where
ORDER BY $orderBy
""".trimIndent()

View File

@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V168_SingleMessageT
import org.thoughtcrime.securesms.database.helpers.migration.V169_EmojiSearchIndexRank
import org.thoughtcrime.securesms.database.helpers.migration.V170_CallTableMigration
import org.thoughtcrime.securesms.database.helpers.migration.V171_ThreadForeignKeyFix
import org.thoughtcrime.securesms.database.helpers.migration.V172_GroupMembershipMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -35,7 +36,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 171
const val DATABASE_VERSION = 172
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -130,6 +131,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 171) {
V171_ThreadForeignKeyFix.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < 172) {
V172_GroupMembershipMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic

View File

@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.core.content.contentValuesOf
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.signal.core.util.SqlUtil
import org.signal.core.util.readToList
import org.signal.core.util.requireNonNullString
/**
* Migrates all IDs from the GroupTable into the GroupMembershipTable
*/
@Suppress("ClassName")
object V172_GroupMembershipMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL(
"""
CREATE TABLE group_membership (
_id INTEGER PRIMARY KEY,
group_id TEXT NOT NULL,
recipient_id INTEGER NOT NULL,
UNIQUE(group_id, recipient_id)
);
""".trimIndent()
)
//language=sql
val total = db.query("SELECT COUNT(*) FROM groups").use {
if (it.moveToFirst()) {
it.getInt(0)
} else {
0
}
}
(0..total).chunked(500).forEachIndexed { index, _ ->
//language=sql
val groupIdToMembers: List<Pair<String, List<Long>>> = db.query("SELECT members, group_id FROM groups LIMIT 500 OFFSET ${index * 500}").readToList { cursor ->
val groupId = cursor.requireNonNullString("group_id")
val members: List<Long> = cursor.requireNonNullString("members").split(",").filterNot { it.isEmpty() }.map { it.toLong() }
groupId to members
}
for ((group_id, members) in groupIdToMembers) {
val queries = SqlUtil.buildBulkInsert(
"group_membership",
arrayOf("group_id", "recipient_id"),
members.map {
contentValuesOf(
"group_id" to group_id,
"recipient_id" to it
)
}
)
for (query in queries) {
db.execSQL(query.where, query.whereArgs)
}
}
}
db.execSQL("ALTER TABLE groups DROP COLUMN members")
}
}

View File

@ -245,7 +245,7 @@ public class MmsDownloadJob extends BaseJob {
}
if (members.size() > 2) {
List<RecipientId> recipients = new ArrayList<>(members);
Set<RecipientId> recipients = new HashSet<>(members);
group = Optional.of(SignalDatabase.groups().getOrCreateMmsGroupForMembers(recipients));
}
IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, TimeUnit.SECONDS.toMillis(retrieved.getDate()), -1, System.currentTimeMillis(), attachments, subscriptionId, 0, false, false, false, Optional.of(sharedContacts), false, false);

View File

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.database
import android.database.Cursor
import com.google.protobuf.ByteString
import org.signal.core.util.requireBlob
import org.signal.core.util.requireString
import org.signal.spinner.ColumnTransformer
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember
import org.signal.storageservice.protos.groups.local.DecryptedGroup
@ -14,7 +13,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil
object GV2Transformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean {
return columnName == GroupTable.V2_DECRYPTED_GROUP || columnName == GroupTable.MEMBERS
return columnName == GroupTable.V2_DECRYPTED_GROUP
}
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String {
@ -23,8 +22,7 @@ object GV2Transformer : ColumnTransformer {
val group = DecryptedGroup.parseFrom(groupBytes)
group.formatAsHtml()
} else {
val members = cursor.requireString(GroupTable.MEMBERS)
members?.split(',')?.chunked(20)?.joinToString("<br>") { it.joinToString(",") } ?: ""
""
}
}
}