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" /> <package name="io.ktor" alias="false" withSubpackages="true" />
</value> </value>
</option> </option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="HTML"> <codeStyleSettings language="HTML">
<indentOptions> <indentOptions>

View file

@ -64,7 +64,7 @@ class SafetyNumberChangeDialogPreviewer {
SafetyNumberBottomSheet SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations( .forIdentityRecordsAndDestinations(
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords, identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId)) destinations = listOf(ContactSearchKey.RecipientSearchKey(myStoryRecipientId, true))
) )
.show(conversationActivity.supportFragmentManager) .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 @Test
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() { fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
val recipients = harness.others 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() val result = subjectUnderTest.getBuckets(recipients, destinations).test()
@ -42,7 +42,7 @@ class SafetyNumberBottomSheetRepositoryTest {
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() { fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
// GIVEN // GIVEN
val recipients = harness.others 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 // WHEN
val result = subjectUnderTest.getBuckets(recipients, destination).test(1) val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
@ -59,7 +59,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN // GIVEN
val distributionListMembers = harness.others.take(5) val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! 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 // WHEN
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1) val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
@ -82,7 +82,7 @@ class SafetyNumberBottomSheetRepositoryTest {
val distributionListMembers = harness.others.take(5) val distributionListMembers = harness.others.take(5)
val toRemove = distributionListMembers.last() val toRemove = distributionListMembers.last()
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! 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) val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions() testScheduler.triggerActions()
@ -108,7 +108,7 @@ class SafetyNumberBottomSheetRepositoryTest {
// GIVEN // GIVEN
val distributionListMembers = harness.others.take(5) val distributionListMembers = harness.others.take(5)
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! 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) val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
testScheduler.triggerActions() 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.buildArgs
import org.signal.core.util.SqlUtil.buildCaseInsensitiveGlobPattern import org.signal.core.util.SqlUtil.buildCaseInsensitiveGlobPattern
import org.signal.core.util.SqlUtil.buildCollectionQuery import org.signal.core.util.SqlUtil.buildCollectionQuery
import org.signal.core.util.delete
import org.signal.core.util.exists import org.signal.core.util.exists
import org.signal.core.util.isAbsent import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log 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.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId 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.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer 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.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.UuidUtil
import java.io.Closeable import java.io.Closeable
import java.lang.AssertionError
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
import java.security.SecureRandom import java.security.SecureRandom
import java.util.ArrayList
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import java.util.stream.Collectors import java.util.stream.Collectors
@ -72,12 +68,13 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
companion object { companion object {
private val TAG = Log.tag(GroupTable::class.java) private val TAG = Log.tag(GroupTable::class.java)
const val MEMBER_GROUP_CONCAT = "member_group_concat"
const val TABLE_NAME = "groups" const val TABLE_NAME = "groups"
const val ID = "_id" const val ID = "_id"
const val GROUP_ID = "group_id" const val GROUP_ID = "group_id"
const val RECIPIENT_ID = "recipient_id" const val RECIPIENT_ID = "recipient_id"
const val TITLE = "title" const val TITLE = "title"
const val MEMBERS = "members"
const val AVATAR_ID = "avatar_id" const val AVATAR_ID = "avatar_id"
const val AVATAR_KEY = "avatar_key" const val AVATAR_KEY = "avatar_key"
const val AVATAR_CONTENT_TYPE = "avatar_content_type" const val AVATAR_CONTENT_TYPE = "avatar_content_type"
@ -112,7 +109,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
$GROUP_ID TEXT, $GROUP_ID TEXT,
$RECIPIENT_ID INTEGER, $RECIPIENT_ID INTEGER,
$TITLE TEXT, $TITLE TEXT,
$MEMBERS TEXT,
$AVATAR_ID INTEGER, $AVATAR_ID INTEGER,
$AVATAR_KEY BLOB, $AVATAR_KEY BLOB,
$AVATAR_CONTENT_TYPE TEXT, $AVATAR_CONTENT_TYPE TEXT,
@ -145,7 +141,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
GROUP_ID, GROUP_ID,
RECIPIENT_ID, RECIPIENT_ID,
TITLE, TITLE,
MEMBERS,
UNMIGRATED_V1_MEMBERS, UNMIGRATED_V1_MEMBERS,
AVATAR_ID, AVATAR_ID,
AVATAR_KEY, AVATAR_KEY,
@ -165,43 +160,77 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
.filterNot { it == RECIPIENT_ID } .filterNot { it == RECIPIENT_ID }
.map { columnName: String -> "$TABLE_NAME.$columnName" } .map { columnName: String -> "$TABLE_NAME.$columnName" }
.toList() .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> { fun getGroup(recipientId: RecipientId): Optional<GroupRecord> {
readableDatabase return getGroup(SqlUtil.Query("$TABLE_NAME.$RECIPIENT_ID = ?", buildArgs(recipientId)))
.select()
.from(TABLE_NAME)
.where("$RECIPIENT_ID = ?", recipientId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
} }
fun getGroup(groupId: GroupId): Optional<GroupRecord> { 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 readableDatabase
.select() .query(select, query.whereArgs)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString())
.run()
.use { cursor -> .use { cursor ->
return if (cursor.moveToFirst()) { return if (cursor.moveToFirst()) {
val groupRecord = getGroup(cursor) val groupRecord = getGroup(cursor)
if (groupRecord.isPresent && RemappedRecords.getInstance().areAnyRemapped(groupRecord.get().members)) { if (groupRecord.isPresent && RemappedRecords.getInstance().areAnyRemapped(groupRecord.get().members)) {
val groupId = groupRecord.get().id
val remaps = RemappedRecords.getInstance().buildRemapDescription(groupRecord.get().members) 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) 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 var updateCount = 0
.update(TABLE_NAME) if (oldToNew.isNotEmpty()) {
.values(MEMBERS to remapped.serialize()) writableDatabase.withinTransaction { db ->
.where("$GROUP_ID = ?", groupId) for ((old, new) in oldToNew) {
.run() 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) { if (updateCount > 0) {
getGroup(groupId) 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. * @return A gv1 group whose expected v2 ID matches the one provided.
*/ */
fun getGroupV1ByExpectedV2(gv2Id: GroupId.V2): Optional<GroupRecord> { fun getGroupV1ByExpectedV2(gv2Id: GroupId.V2): Optional<GroupRecord> {
readableDatabase return getGroup(SqlUtil.Query("$TABLE_NAME.$EXPECTED_V2_ID = ?", buildArgs(gv2Id)))
.select(*GROUP_PROJECTION)
.from(TABLE_NAME)
.where("$EXPECTED_V2_ID = ?", gv2Id)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
} }
fun getGroupByDistributionId(distributionId: DistributionId): Optional<GroupRecord> { fun getGroupByDistributionId(distributionId: DistributionId): Optional<GroupRecord> {
readableDatabase return getGroup(SqlUtil.Query("$TABLE_NAME.$DISTRIBUTION_ID = ?", buildArgs(distributionId)))
.select()
.from(TABLE_NAME)
.where("$DISTRIBUTION_ID = ?", distributionId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
getGroup(cursor)
} else {
Optional.empty()
}
}
} }
fun removeUnmigratedV1Members(id: GroupId.V2) { 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 { fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms) 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) return Reader(cursor)
} }
@ -353,21 +367,17 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
recipientIds = recipientIds.take(30).toSet() recipientIds = recipientIds.take(30).toSet()
} }
val recipientLikeClauses = recipientIds val membershipQuery = SqlUtil.buildSingleCollectionQuery("${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}", recipientIds)
.map { it.toLong() }
.map { id -> "($MEMBERS LIKE $id || ',%' OR $MEMBERS LIKE '%,' || $id || ',%' OR $MEMBERS LIKE '%,' || $id)" }
.toList()
var query: String var query: String
val queryArgs: Array<String> val queryArgs: Array<String>
val membershipQuery = "(" + Util.join(recipientLikeClauses, " OR ") + ")"
if (includeInactive) { if (includeInactive) {
query = "$membershipQuery AND ($ACTIVE = ? OR $RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))" query = "${membershipQuery.where} AND ($ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))"
queryArgs = buildArgs(1) queryArgs = membershipQuery.whereArgs + buildArgs(1)
} else { } else {
query = "$membershipQuery AND $ACTIVE = ?" query = "${membershipQuery.where} AND $ACTIVE = ?"
queryArgs = buildArgs(1) queryArgs = membershipQuery.whereArgs + buildArgs(1)
} }
if (excludeV1) { if (excludeV1) {
@ -378,15 +388,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
query += " AND $MMS = 0" 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 { private fun queryGroupsByRecency(groupQuery: GroupQuery): Reader {
val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms) val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms)
val sql = """ val sql = """
SELECT $TABLE_NAME.* $JOINED_GROUP_SELECT
FROM $TABLE_NAME LEFT JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}
WHERE ${query.where} WHERE ${query.where}
${"GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}"}
ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC
""".toSingleLine() """.toSingleLine()
@ -407,7 +417,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery) val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery)
if (includeInactive) { 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) queryArgs = buildArgs(caseInsensitiveQuery, 1)
} else { } else {
query = "$TITLE GLOB ? AND $ACTIVE = ?" query = "$TITLE GLOB ? AND $ACTIVE = ?"
@ -456,23 +466,27 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
} }
fun getOrCreateMmsGroupForMembers(members: List<RecipientId>): GroupId.Mms { fun getOrCreateMmsGroupForMembers(members: Set<RecipientId>): GroupId.Mms {
val sortedMembers = members.sorted() //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 return readableDatabase.query(statement).use { cursor ->
.select(GROUP_ID) if (cursor.moveToNext()) {
.from(TABLE_NAME) return GroupId.parseOrThrow(cursor.requireNonNullString("gid")).requireMms()
.where("$MEMBERS = ? AND $MMS = ?", sortedMembers.serialize(), 1) } else {
.run() val groupId = GroupId.createMms(SecureRandom())
.use { cursor -> create(groupId, null, members)
return if (cursor.moveToNext()) { groupId
GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)).requireMms()
} else {
val groupId = GroupId.createMms(SecureRandom())
create(groupId, null, sortedMembers)
groupId
}
} }
}
} }
@WorkerThread @WorkerThread
@ -493,9 +507,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
@WorkerThread @WorkerThread
fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean, includeInactive: Boolean): List<GroupRecord> { 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}" //language=sql
var query = "$MEMBERS LIKE ?" val table = """
var args = buildArgs("%${recipientId.serialize()}%") $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" val orderBy = "${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC"
if (pushOnly) { if (pushOnly) {
@ -509,23 +528,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
return readableDatabase 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 -> .readToList { cursor ->
val serializedMembers = cursor.requireNonNullString(MEMBERS) getGroup(cursor).get()
if (RecipientId.serializedListContains(serializedMembers, recipientId)) {
getGroup(cursor).get()
} else {
null
}
} }
.filterNotNull()
} }
fun getGroups(): Reader { fun getGroups(): Reader {
val cursor = readableDatabase val cursor = readableDatabase.query("$JOINED_GROUP_SELECT GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}")
.select()
.from(TABLE_NAME)
.run()
return Reader(cursor) return Reader(cursor)
} }
@ -648,6 +658,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
groupMasterKey: GroupMasterKey?, groupMasterKey: GroupMasterKey?,
groupState: DecryptedGroup? groupState: DecryptedGroup?
) { ) {
val membershipValues = mutableListOf<ContentValues>()
val groupRecipientId = recipients.getOrInsertFromGroupId(groupId) val groupRecipientId = recipients.getOrInsertFromGroupId(groupId)
val members: List<RecipientId> = memberCollection.toSet().sorted() val members: List<RecipientId> = memberCollection.toSet().sorted()
var groupMembers: List<RecipientId> = members var groupMembers: List<RecipientId> = members
@ -657,7 +668,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
values.put(RECIPIENT_ID, groupRecipientId.serialize()) values.put(RECIPIENT_ID, groupRecipientId.serialize())
values.put(GROUP_ID, groupId.toString()) values.put(GROUP_ID, groupId.toString())
values.put(TITLE, title) values.put(TITLE, title)
values.put(MEMBERS, members.serialize()) membershipValues.addAll(members.toContentValues(groupId))
values.put(MMS, groupId.isMms) values.put(MMS, groupId.isMms)
if (avatar != null) { if (avatar != null) {
@ -693,14 +704,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
values.put(V2_MASTER_KEY, groupMasterKey.serialize()) values.put(V2_MASTER_KEY, groupMasterKey.serialize())
values.put(V2_REVISION, groupState.revision) values.put(V2_REVISION, groupState.revision)
values.put(V2_DECRYPTED_GROUP, groupState.toByteArray()) values.put(V2_DECRYPTED_GROUP, groupState.toByteArray())
values.put(MEMBERS, groupMembers.serialize()) membershipValues.clear()
membershipValues.addAll(groupMembers.toContentValues(groupId))
} else { } else {
if (groupId.isV2) { if (groupId.isV2) {
throw AssertionError("V2 group id but no master key") 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()) { if (groupState != null && groupState.hasDisappearingMessagesTimer()) {
recipients.setExpireMessages(groupRecipientId, groupState.disappearingMessagesTimer.duration) recipients.setExpireMessages(groupRecipientId, groupState.disappearingMessagesTimer.duration)
@ -829,7 +851,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
val groupMembers = getV2GroupMembers(decryptedGroup, true) val groupMembers = getV2GroupMembers(decryptedGroup, true)
contentValues.put(MEMBERS, groupMembers.serialize())
if (existingGroup.isPresent && existingGroup.get().isV2Group) { if (existingGroup.isPresent && existingGroup.get().isV2Group) {
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
@ -842,11 +863,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
} }
writableDatabase writableDatabase.withinTransaction { database ->
.update(TABLE_NAME) database
.values(contentValues) .update(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString()) .values(contentValues)
.run() .where("$GROUP_ID = ?", groupId.toString())
.run()
performMembershipUpdate(database, groupId, groupMembers)
}
if (decryptedGroup.hasDisappearingMessagesTimer()) { if (decryptedGroup.hasDisappearingMessagesTimer()) {
recipients.setExpireMessages(groupRecipientId, decryptedGroup.disappearingMessagesTimer.duration) recipients.setExpireMessages(groupRecipientId, decryptedGroup.disappearingMessagesTimer.duration)
@ -898,27 +923,24 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
fun updateMembers(groupId: GroupId, members: List<RecipientId>) { fun updateMembers(groupId: GroupId, members: List<RecipientId>) {
writableDatabase writableDatabase.withinTransaction { database ->
.update(TABLE_NAME) database
.values( .update(TABLE_NAME)
MEMBERS to members.sorted().serialize(), .values(ACTIVE to 1)
ACTIVE to 1 .where("$GROUP_ID = ?", groupId)
) .run()
.where("$GROUP_ID = ?", groupId)
.run() performMembershipUpdate(database, groupId, members)
}
val groupRecipient = recipients.getOrInsertFromGroupId(groupId) val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh() Recipient.live(groupRecipient).refresh()
} }
fun remove(groupId: GroupId, source: RecipientId) { fun remove(groupId: GroupId, source: RecipientId) {
val currentMembers: MutableList<RecipientId> = getCurrentMembers(groupId)
currentMembers -= source
writableDatabase writableDatabase
.update(TABLE_NAME) .delete(MembershipTable.TABLE_NAME)
.values(MEMBERS to currentMembers.serialize()) .where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, source)
.where("$GROUP_ID = ?", groupId)
.run() .run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId) val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
@ -927,17 +949,34 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
private fun getCurrentMembers(groupId: GroupId): MutableList<RecipientId> { private fun getCurrentMembers(groupId: GroupId): MutableList<RecipientId> {
return readableDatabase return readableDatabase
.select(MEMBERS) .select(MembershipTable.RECIPIENT_ID)
.from(TABLE_NAME) .from(MembershipTable.TABLE_NAME)
.where("$GROUP_ID = ?", groupId) .where("${MembershipTable.GROUP_ID} = ?", groupId)
.run() .run()
.readToList { cursor -> .readToList { cursor ->
val serializedMembers = cursor.requireNonNullString(MEMBERS) RecipientId.from(cursor.requireLong(MembershipTable.RECIPIENT_ID))
return RecipientId.fromSerializedList(serializedMembers)
} }
.toMutableList() .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 { fun isActive(groupId: GroupId): Boolean {
val record = getGroup(groupId) val record = getGroup(groupId)
return record.isPresent && record.get().isActive return record.isPresent && record.get().isActive
@ -961,19 +1000,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
@WorkerThread @WorkerThread
fun isCurrentMember(groupId: Push, recipientId: RecipientId): Boolean { fun isCurrentMember(groupId: Push, recipientId: RecipientId): Boolean {
readableDatabase return readableDatabase
.select(MEMBERS) .exists(MembershipTable.TABLE_NAME)
.from(TABLE_NAME) .where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, recipientId)
.where("$GROUP_ID = ?", groupId)
.run() .run()
.use { cursor ->
return if (cursor.moveToFirst()) {
val serializedMembers = cursor.requireNonNullString(MEMBERS)
RecipientId.serializedListContains(serializedMembers, recipientId)
} else {
false
}
}
} }
fun getAllGroupV2Ids(): List<GroupId.V2> { fun getAllGroupV2Ids(): List<GroupId.V2> {
@ -1005,15 +1035,13 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
} }
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { 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)) { 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) { if (group.isV2Group) {
removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId)) removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId))
} }
@ -1042,7 +1070,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
id = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)), id = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)),
recipientId = RecipientId.from(cursor.requireNonNullString(RECIPIENT_ID)), recipientId = RecipientId.from(cursor.requireNonNullString(RECIPIENT_ID)),
title = cursor.requireString(TITLE), title = cursor.requireString(TITLE),
serializedMembers = cursor.requireString(MEMBERS), serializedMembers = cursor.requireString(MEMBER_GROUP_CONCAT),
serializedUnmigratedV1Members = cursor.requireString(UNMIGRATED_V1_MEMBERS), serializedUnmigratedV1Members = cursor.requireString(UNMIGRATED_V1_MEMBERS),
avatarId = cursor.requireLong(AVATAR_ID), avatarId = cursor.requireLong(AVATAR_ID),
avatarKey = cursor.requireBlob(AVATAR_KEY), avatarKey = cursor.requireBlob(AVATAR_KEY),
@ -1252,6 +1280,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
return RecipientId.toSerializedList(this) 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> { private fun uuidsToRecipientIds(uuids: List<UUID>): MutableList<RecipientId> {
return uuids return uuids
.asSequence() .asSequence()

View file

@ -86,7 +86,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
db.execSQL(IdentityTable.CREATE_TABLE) db.execSQL(IdentityTable.CREATE_TABLE)
db.execSQL(DraftTable.CREATE_TABLE) db.execSQL(DraftTable.CREATE_TABLE)
db.execSQL(PushTable.CREATE_TABLE) db.execSQL(PushTable.CREATE_TABLE)
db.execSQL(GroupTable.CREATE_TABLE) executeStatements(db, GroupTable.CREATE_TABLES)
db.execSQL(RecipientTable.CREATE_TABLE) db.execSQL(RecipientTable.CREATE_TABLE)
db.execSQL(GroupReceiptTable.CREATE_TABLE) db.execSQL(GroupReceiptTable.CREATE_TABLE)
db.execSQL(OneTimePreKeyTable.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 { private fun createQuery(where: String, orderBy: String, offset: Long, limit: Long): String {
val projection = COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION.joinToString(separator = ",") val projection = COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION.joinToString(separator = ",")
//language=sql
var query = """ var query = """
SELECT $projection SELECT $projection, ${GroupTable.MEMBER_GROUP_CONCAT}
FROM $TABLE_NAME FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} 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} LEFT OUTER JOIN ${GroupTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
WHERE $where 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 ORDER BY $orderBy
""".trimIndent() """.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.V169_EmojiSearchIndexRank
import org.thoughtcrime.securesms.database.helpers.migration.V170_CallTableMigration 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.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. * 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) val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 171 const val DATABASE_VERSION = 172
@JvmStatic @JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -130,6 +131,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 171) { if (oldVersion < 171) {
V171_ThreadForeignKeyFix.migrate(context, db, oldVersion, newVersion) V171_ThreadForeignKeyFix.migrate(context, db, oldVersion, newVersion)
} }
if (oldVersion < 172) {
V172_GroupMembershipMigration.migrate(context, db, oldVersion, newVersion)
}
} }
@JvmStatic @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) { if (members.size() > 2) {
List<RecipientId> recipients = new ArrayList<>(members); Set<RecipientId> recipients = new HashSet<>(members);
group = Optional.of(SignalDatabase.groups().getOrCreateMmsGroupForMembers(recipients)); 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); 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 android.database.Cursor
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import org.signal.core.util.requireBlob import org.signal.core.util.requireBlob
import org.signal.core.util.requireString
import org.signal.spinner.ColumnTransformer import org.signal.spinner.ColumnTransformer
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember import org.signal.storageservice.protos.groups.local.DecryptedBannedMember
import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedGroup
@ -14,7 +13,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil
object GV2Transformer : ColumnTransformer { object GV2Transformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean { 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 { override fun transform(tableName: String?, columnName: String, cursor: Cursor): String {
@ -23,8 +22,7 @@ object GV2Transformer : ColumnTransformer {
val group = DecryptedGroup.parseFrom(groupBytes) val group = DecryptedGroup.parseFrom(groupBytes)
group.formatAsHtml() group.formatAsHtml()
} else { } else {
val members = cursor.requireString(GroupTable.MEMBERS) ""
members?.split(',')?.chunked(20)?.joinToString("<br>") { it.joinToString(",") } ?: ""
} }
} }
} }