Signal-Android/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt

1487 wiersze
52 KiB
Kotlin

package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.text.TextUtils
import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf
import org.intellij.lang.annotations.Language
import org.signal.core.util.SetUtil
import org.signal.core.util.SqlUtil
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
import org.signal.core.util.optionalString
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
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.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toSingleLine
import org.signal.core.util.update
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.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.crypto.SenderKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupId.Push
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
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.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
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.security.SecureRandom
import java.util.Optional
import java.util.UUID
import java.util.stream.Collectors
class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference {
companion object {
private val TAG = Log.tag(GroupTable::class.java)
const val MEMBER_GROUP_CONCAT = "member_group_concat"
const val THREAD_DATE = "thread_date"
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 AVATAR_ID = "avatar_id"
const val AVATAR_KEY = "avatar_key"
const val AVATAR_CONTENT_TYPE = "avatar_content_type"
const val AVATAR_RELAY = "avatar_relay"
const val AVATAR_DIGEST = "avatar_digest"
const val TIMESTAMP = "timestamp"
const val ACTIVE = "active"
const val MMS = "mms"
const val EXPECTED_V2_ID = "expected_v2_id"
const val UNMIGRATED_V1_MEMBERS = "former_v1_members"
const val DISTRIBUTION_ID = "distribution_id"
const val SHOW_AS_STORY_STATE = "display_as_story"
const val LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp"
/** 32 bytes serialized [GroupMasterKey] */
const val V2_MASTER_KEY = "master_key"
/** Increments with every change to the group */
const val V2_REVISION = "revision"
/** Serialized [DecryptedGroup] protobuf */
const val V2_DECRYPTED_GROUP = "decrypted_group"
/** Was temporarily used for PNP accept by pni but is no longer needed/updated */
@Deprecated("")
private val AUTH_SERVICE_ID = "auth_service_id"
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$GROUP_ID TEXT,
$RECIPIENT_ID INTEGER,
$TITLE TEXT,
$AVATAR_ID INTEGER,
$AVATAR_KEY BLOB,
$AVATAR_CONTENT_TYPE TEXT,
$AVATAR_RELAY TEXT,
$TIMESTAMP INTEGER,
$ACTIVE INTEGER DEFAULT 1,
$AVATAR_DIGEST BLOB,
$MMS INTEGER DEFAULT 0,
$V2_MASTER_KEY BLOB,
$V2_REVISION BLOB,
$V2_DECRYPTED_GROUP BLOB,
$EXPECTED_V2_ID TEXT DEFAULT NULL,
$UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL,
$DISTRIBUTION_ID TEXT DEFAULT NULL,
$SHOW_AS_STORY_STATE INTEGER DEFAULT 0,
$AUTH_SERVICE_ID TEXT DEFAULT NULL,
$LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0
)
"""
@JvmField
val CREATE_INDEXS = arrayOf(
"CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON $TABLE_NAME ($GROUP_ID);",
"CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);",
"CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON $TABLE_NAME ($EXPECTED_V2_ID);",
"CREATE UNIQUE INDEX IF NOT EXISTS group_distribution_id_index ON $TABLE_NAME($DISTRIBUTION_ID);"
)
private val GROUP_PROJECTION = arrayOf(
GROUP_ID,
RECIPIENT_ID,
TITLE,
UNMIGRATED_V1_MEMBERS,
AVATAR_ID,
AVATAR_KEY,
AVATAR_CONTENT_TYPE,
AVATAR_RELAY,
AVATAR_DIGEST,
TIMESTAMP,
ACTIVE,
MMS,
V2_MASTER_KEY,
V2_REVISION,
V2_DECRYPTED_GROUP,
LAST_FORCE_UPDATE_TIMESTAMP
)
val TYPED_GROUP_PROJECTION = GROUP_PROJECTION
.filterNot { it == RECIPIENT_ID }
.map { columnName: String -> "$TABLE_NAME.$columnName" }
.toList()
//language=sql
private val JOINED_GROUP_SELECT = """
SELECT
DISTINCT $TABLE_NAME.*,
(
SELECT GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID})
FROM ${MembershipTable.TABLE_NAME}
WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
) as $MEMBER_GROUP_CONCAT
FROM $TABLE_NAME
""".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> {
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}"
readableDatabase
.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 oldToNew: List<Pair<RecipientId, RecipientId?>> = groupRecord.get().members.map {
it to RemappedRecords.getInstance().getRecipient(it).orElse(null)
}.filterNot { (old, new) -> new == null || old == new }
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) {
Log.i(TAG, "Successfully updated $updateCount rows. GroupId: $groupId, Remaps: $remaps", true)
} else {
Log.w(TAG, "Failed to update any rows. GroupId: $groupId, Remaps: $remaps", true)
}
}
getGroup(cursor)
} else {
Optional.empty()
}
}
}
/**
* Call if you are sure this group should exist.
* Finds group and throws if it cannot.
*/
fun requireGroup(groupId: GroupId): GroupRecord {
val group = getGroup(groupId)
if (!group.isPresent) {
throw AssertionError("Group not found")
}
return group.get()
}
fun groupExists(groupId: GroupId): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString())
.run()
}
/**
* @return A gv1 group whose expected v2 ID matches the one provided.
*/
fun getGroupV1ByExpectedV2(gv2Id: GroupId.V2): Optional<GroupRecord> {
return getGroup(SqlUtil.Query("$TABLE_NAME.$EXPECTED_V2_ID = ?", buildArgs(gv2Id)))
}
fun getGroupByDistributionId(distributionId: DistributionId): Optional<GroupRecord> {
return getGroup(SqlUtil.Query("$TABLE_NAME.$DISTRIBUTION_ID = ?", buildArgs(distributionId)))
}
fun removeUnmigratedV1Members(id: GroupId.V2) {
val group = getGroup(id)
if (!group.isPresent) {
Log.w(TAG, "Couldn't find the group!", Throwable())
return
}
removeUnmigratedV1Members(id, group.get().unmigratedV1Members)
}
/**
* Removes the specified members from the list of 'unmigrated V1 members' -- the list of members
* that were either dropped or had to be invited when migrating the group from V1->V2.
*/
fun removeUnmigratedV1Members(id: GroupId.V2, toRemove: List<RecipientId>) {
val group = getGroup(id)
if (group.isAbsent()) {
Log.w(TAG, "Couldn't find the group!", Throwable())
return
}
val newUnmigrated = group.get().unmigratedV1Members - toRemove.toSet()
writableDatabase
.update(TABLE_NAME)
.values(UNMIGRATED_V1_MEMBERS to if (newUnmigrated.isEmpty()) null else newUnmigrated.serialize())
.where("$GROUP_ID = ?", id)
.run()
Recipient.live(Recipient.externalGroupExact(id).id).refresh()
}
private fun getGroup(cursor: Cursor?): Optional<GroupRecord> {
val reader = Reader(cursor)
return Optional.ofNullable(reader.getCurrent())
}
/**
* @return local db group revision or -1 if not present.
*/
fun getGroupV2Revision(groupId: GroupId.V2): Int {
readableDatabase
.select()
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId.toString())
.run()
.use { cursor ->
return if (cursor.moveToNext()) {
cursor.getInt(cursor.getColumnIndexOrThrow(V2_REVISION))
} else {
-1
}
}
}
fun isUnknownGroup(groupId: GroupId): Boolean {
val group = getGroup(groupId)
if (!group.isPresent) {
return true
}
val noMetadata = !group.get().hasAvatar() && group.get().title.isNullOrEmpty()
val noMembers = group.get().members.isEmpty() || group.get().members.size == 1 && group.get().members.contains(Recipient.self().id)
return noMetadata && noMembers
}
fun queryGroupsByMemberName(inputQuery: String): Cursor {
val subquery = recipients.getAllContactsSubquery(inputQuery)
val statement = """
SELECT
DISTINCT $TABLE_NAME.*,
GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) as $MEMBER_GROUP_CONCAT,
${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} as $THREAD_DATE
FROM $TABLE_NAME
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE $ACTIVE = 1 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
""".toSingleLine()
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs)
}
fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms)
//language=sql
val statement = """
$JOINED_GROUP_SELECT
WHERE ${query.where}
ORDER BY $TITLE COLLATE NOCASE ASC
""".trimIndent()
val cursor = databaseHelper.signalReadableDatabase.query(statement, query.whereArgs)
return Reader(cursor)
}
fun queryGroupsByMembership(recipientIds: Set<RecipientId>, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
var recipientIds = recipientIds
if (recipientIds.isEmpty()) {
return Reader(null)
}
if (recipientIds.size > 30) {
Log.w(TAG, "[queryGroupsByMembership] Large set of recipientIds (${recipientIds.size})! Using the first 30.")
recipientIds = recipientIds.take(30).toSet()
}
val membershipQuery = SqlUtil.buildSingleCollectionQuery("${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}", recipientIds)
var query: String
val queryArgs: Array<String>
if (includeInactive) {
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.where} AND $ACTIVE = ?"
queryArgs = membershipQuery.whereArgs + buildArgs(1)
}
if (excludeV1) {
query += " AND $EXPECTED_V2_ID IS NULL"
}
if (excludeMms) {
query += " AND $MMS = 0"
}
val selection = """
SELECT DISTINCT
$TABLE_NAME.*,
(
SELECT GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID})
FROM ${MembershipTable.TABLE_NAME}
WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
) as $MEMBER_GROUP_CONCAT
FROM ${MembershipTable.TABLE_NAME}
INNER JOIN $TABLE_NAME ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
WHERE $query
""".trimIndent()
return Reader(readableDatabase.query(selection, queryArgs))
}
private fun queryGroupsByRecency(groupQuery: GroupQuery): Reader {
val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms)
val sql = """
$JOINED_GROUP_SELECT
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE ${query.where}
ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC
""".toSingleLine()
return Reader(databaseHelper.signalReadableDatabase.rawQuery(sql, query.whereArgs))
}
fun queryGroups(groupQuery: GroupQuery): Reader {
return if (groupQuery.sortOrder === ContactSearchSortOrder.NATURAL) {
queryGroupsByTitle(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms)
} else {
queryGroupsByRecency(groupQuery)
}
}
private fun getGroupQueryWhereStatement(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): SqlUtil.Query {
var query: String
val queryArgs: Array<String>
val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery)
if (includeInactive) {
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 = ?"
queryArgs = buildArgs(caseInsensitiveQuery, 1)
}
if (excludeV1) {
query += " AND $EXPECTED_V2_ID IS NULL"
}
if (excludeMms) {
query += " AND $MMS = 0"
}
return SqlUtil.Query(query, queryArgs)
}
fun getOrCreateDistributionId(groupId: GroupId.V2): DistributionId {
readableDatabase
.select(DISTRIBUTION_ID)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
val serialized = cursor.optionalString(DISTRIBUTION_ID)
if (serialized.isPresent) {
DistributionId.from(serialized.get())
} else {
Log.w(TAG, "Missing distributionId! Creating one.")
val distributionId = DistributionId.create()
val count = writableDatabase
.update(TABLE_NAME)
.values(DISTRIBUTION_ID to distributionId.toString())
.where("$GROUP_ID = ?", groupId)
.run()
check(count >= 1) { "Tried to create a distributionId for $groupId, but it doesn't exist!" }
distributionId
}
} else {
throw IllegalStateException("Group $groupId doesn't exist!")
}
}
}
fun getOrCreateMmsGroupForMembers(members: Set<RecipientId>): GroupId.Mms {
val joinedTestMembers = members
.toList()
.map { it.toLong() }
.sorted()
.joinToString(separator = ",")
//language=sql
val statement = """
SELECT
$TABLE_NAME.$GROUP_ID as gid,
(
SELECT GROUP_CONCAT(${MembershipTable.RECIPIENT_ID}, ',')
FROM (
SELECT ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}
FROM ${MembershipTable.TABLE_NAME}
WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
ORDER BY ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} ASC
)
) as $MEMBER_GROUP_CONCAT
FROM $TABLE_NAME
WHERE $MEMBER_GROUP_CONCAT = ?
""".toSingleLine()
return readableDatabase.rawQuery(statement, buildArgs(joinedTestMembers)).use { cursor ->
if (cursor.moveToNext()) {
return GroupId.parseOrThrow(cursor.requireNonNullString("gid")).requireMms()
} else {
val groupId = GroupId.createMms(SecureRandom())
create(groupId, null, members)
groupId
}
}
}
@WorkerThread
fun getPushGroupNamesContainingMember(recipientId: RecipientId): List<String> {
return getPushGroupsContainingMember(recipientId)
.map { groupRecord -> Recipient.resolved(groupRecord.recipientId).getDisplayName(context) }
.toList()
}
@WorkerThread
fun getPushGroupsContainingMember(recipientId: RecipientId): List<GroupRecord> {
return getGroupsContainingMember(recipientId, true)
}
fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean): List<GroupRecord> {
return getGroupsContainingMember(recipientId, pushOnly, false)
}
@WorkerThread
fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean, includeInactive: Boolean): List<GroupRecord> {
//language=sql
val table = """
SELECT
DISTINCT $TABLE_NAME.*,
(
SELECT GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID})
FROM ${MembershipTable.TABLE_NAME}
WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
) as $MEMBER_GROUP_CONCAT
FROM ${MembershipTable.TABLE_NAME}
INNER JOIN $TABLE_NAME ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
LEFT 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) {
query += " AND $MMS = ?"
args = appendArg(args, "0")
}
if (!includeInactive) {
query += " AND $ACTIVE = ?"
args = appendArg(args, "1")
}
return readableDatabase
.query("$table WHERE $query ORDER BY $orderBy", args)
.readToList { cursor ->
getGroup(cursor).get()
}
}
fun getGroups(): Reader {
val cursor = readableDatabase.query(JOINED_GROUP_SELECT)
return Reader(cursor)
}
fun getActiveGroupCount(): Int {
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = ?", 1)
.run()
.readToSingleInt(0)
}
@WorkerThread
fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List<RecipientId> {
return if (groupId.isV2) {
getGroup(groupId)
.map { it.requireV2GroupProperties().getMemberRecipientIds(memberSet) }
.orElse(emptyList())
} else {
val currentMembers: MutableList<RecipientId> = getCurrentMembers(groupId)
if (!memberSet.includeSelf) {
currentMembers -= Recipient.self().id
}
currentMembers
}
}
@WorkerThread
fun getGroupMembers(groupId: GroupId, memberSet: MemberSet): List<Recipient> {
return if (groupId.isV2) {
getGroup(groupId)
.map { it.requireV2GroupProperties().getMemberRecipients(memberSet) }
.orElse(emptyList())
} else {
val currentMembers: List<RecipientId> = getCurrentMembers(groupId)
val recipients: MutableList<Recipient> = ArrayList(currentMembers.size)
for (member in currentMembers) {
val resolved = Recipient.resolved(member)
if (memberSet.includeSelf || !resolved.isSelf) {
recipients += resolved
}
}
recipients
}
}
fun create(groupId: GroupId.V1, title: String?, members: Collection<RecipientId>, avatar: SignalServiceAttachmentPointer?, relay: String?) {
if (groupExists(groupId.deriveV2MigrationGroupId())) {
throw LegacyGroupInsertException(groupId)
}
create(groupId, title, members, avatar, relay, null, null)
}
fun create(groupId: GroupId.Mms, title: String?, members: Collection<RecipientId>) {
create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null)
}
@JvmOverloads
fun create(groupMasterKey: GroupMasterKey, groupState: DecryptedGroup, force: Boolean = false): GroupId.V2 {
val groupId = GroupId.v2(groupMasterKey)
if (!force && getGroupV1ByExpectedV2(groupId).isPresent) {
throw MissedGroupMigrationInsertException(groupId)
} else if (force) {
Log.w(TAG, "Forcing the creation of a group even though we already have a V1 ID!")
}
create(groupId, groupState.title, emptyList(), null, null, groupMasterKey, groupState)
return groupId
}
/**
* There was a point in time where we weren't properly responding to group creates on linked devices. This would result in us having a Recipient entry for the
* group, but we'd either be missing the group entry, or that entry would be missing a master key. This method fixes this scenario.
*/
fun fixMissingMasterKey(groupMasterKey: GroupMasterKey) {
val groupId = GroupId.v2(groupMasterKey)
if (getGroupV1ByExpectedV2(groupId).isPresent) {
Log.w(TAG, "There already exists a V1 group that should be migrated into this group. But if the recipient already exists, there's not much we can do here.")
}
writableDatabase.withinTransaction { db ->
val updated = db
.update(TABLE_NAME)
.values(V2_MASTER_KEY to groupMasterKey.serialize())
.where("$GROUP_ID = ?", groupId)
.run()
if (updated < 1) {
Log.w(TAG, "No group entry. Creating restore placeholder for $groupId")
create(
groupMasterKey,
DecryptedGroup.newBuilder()
.setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION)
.build(),
true
)
} else {
Log.w(TAG, "Had a group entry, but it was missing a master key. Updated.")
}
}
Log.w(TAG, "Scheduling request for latest group info for $groupId")
ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(groupId))
}
/**
* @param groupMasterKey null for V1, must be non-null for V2 (presence dictates group version).
*/
private fun create(
groupId: GroupId,
title: String?,
memberCollection: Collection<RecipientId>,
avatar: SignalServiceAttachmentPointer?,
relay: String?,
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
val values = ContentValues()
values.put(RECIPIENT_ID, groupRecipientId.serialize())
values.put(GROUP_ID, groupId.toString())
values.put(TITLE, title)
membershipValues.addAll(members.toContentValues(groupId))
values.put(MMS, groupId.isMms)
if (avatar != null) {
values.put(AVATAR_ID, avatar.remoteId.v2.get())
values.put(AVATAR_KEY, avatar.key)
values.put(AVATAR_CONTENT_TYPE, avatar.contentType)
values.put(AVATAR_DIGEST, avatar.digest.orElse(null))
} else {
values.put(AVATAR_ID, 0)
}
values.put(AVATAR_RELAY, relay)
values.put(TIMESTAMP, System.currentTimeMillis())
if (groupId.isV2) {
values.put(ACTIVE, if (groupState != null && gv2GroupActive(groupState)) 1 else 0)
values.put(DISTRIBUTION_ID, DistributionId.create().toString())
} else if (groupId.isV1) {
values.put(ACTIVE, 1)
values.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString())
} else {
values.put(ACTIVE, 1)
}
if (groupMasterKey != null) {
if (groupState == null) {
throw AssertionError("V2 master key but no group state")
}
groupId.requireV2()
groupMembers = getV2GroupMembers(groupState, true)
values.put(V2_MASTER_KEY, groupMasterKey.serialize())
values.put(V2_REVISION, groupState.revision)
values.put(V2_DECRYPTED_GROUP, groupState.toByteArray())
membershipValues.clear()
membershipValues.addAll(groupMembers.toContentValues(groupId))
} else {
if (groupId.isV2) {
throw AssertionError("V2 group id but no master key")
}
}
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)
}
if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) {
recipients.setHasGroupsInCommon(groupMembers)
}
Recipient.live(groupRecipientId).refresh()
notifyConversationListListeners()
}
fun update(groupId: GroupId.V1, title: String?, avatar: SignalServiceAttachmentPointer?) {
val contentValues = ContentValues().apply {
if (title != null) {
put(TITLE, title)
}
if (avatar != null) {
put(AVATAR_ID, avatar.remoteId.v2.get())
put(AVATAR_CONTENT_TYPE, avatar.contentType)
put(AVATAR_KEY, avatar.key)
put(AVATAR_DIGEST, avatar.digest.orElse(null))
} else {
put(AVATAR_ID, 0)
}
}
writableDatabase
.update(TABLE_NAME)
.values(contentValues)
.where("$GROUP_ID = ?", groupId)
.run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh()
notifyConversationListListeners()
}
/**
* Migrates a V1 group to a V2 group.
*
* @param decryptedGroup The state that represents the group on the server. This will be used to
* determine if we need to save our old membership list and stuff.
*/
fun migrateToV2(
threadId: Long,
groupIdV1: GroupId.V1,
decryptedGroup: DecryptedGroup
): GroupId.V2 {
val groupIdV2 = groupIdV1.deriveV2MigrationGroupId()
val groupMasterKey = groupIdV1.deriveV2MigrationMasterKey()
writableDatabase.withinTransaction { db ->
val record = getGroup(groupIdV1).get()
val newMembers: MutableList<RecipientId> = DecryptedGroupUtil.membersToUuidList(decryptedGroup.membersList).toRecipientIds()
val pendingMembers: List<RecipientId> = DecryptedGroupUtil.pendingToUuidList(decryptedGroup.pendingMembersList).toRecipientIds()
newMembers.addAll(pendingMembers)
val droppedMembers: List<RecipientId> = SetUtil.difference(record.members, newMembers).toList()
val unmigratedMembers: List<RecipientId> = pendingMembers + droppedMembers
val updated: Int = db.update(TABLE_NAME)
.values(
GROUP_ID to groupIdV2.toString(),
V2_MASTER_KEY to groupMasterKey.serialize(),
DISTRIBUTION_ID to DistributionId.create().toString(),
EXPECTED_V2_ID to null,
UNMIGRATED_V1_MEMBERS to if (unmigratedMembers.isEmpty()) null else unmigratedMembers.serialize()
)
.where("$GROUP_ID = ?", groupIdV1)
.run()
if (updated != 1) {
throw AssertionError()
}
recipients.updateGroupId(groupIdV1, groupIdV2)
update(groupMasterKey, decryptedGroup)
messages.insertGroupV1MigrationEvents(
record.recipientId,
threadId,
GroupMigrationMembershipChange(pendingMembers, droppedMembers)
)
}
return groupIdV2
}
fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup) {
update(GroupId.v2(groupMasterKey), decryptedGroup)
}
fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup) {
val groupRecipientId: RecipientId = recipients.getOrInsertFromGroupId(groupId)
val existingGroup: Optional<GroupRecord> = getGroup(groupId)
val title: String = decryptedGroup.title
val contentValues = ContentValues()
contentValues.put(TITLE, title)
contentValues.put(V2_REVISION, decryptedGroup.revision)
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray())
contentValues.put(ACTIVE, if (gv2GroupActive(decryptedGroup)) 1 else 0)
if (existingGroup.isPresent && existingGroup.get().unmigratedV1Members.isNotEmpty() && existingGroup.get().isV2Group) {
val unmigratedV1Members: MutableSet<RecipientId> = existingGroup.get().unmigratedV1Members.toMutableSet()
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
val addedMembers: Set<RecipientId> = DecryptedGroupUtil.membersToUuidList(change.newMembersList).toRecipientIds().toSet()
val removedMembers: Set<RecipientId> = DecryptedGroupUtil.removedMembersUuidList(change).toRecipientIds().toSet()
val addedInvites: Set<RecipientId> = DecryptedGroupUtil.pendingToUuidList(change.newPendingMembersList).toRecipientIds().toSet()
val removedInvites: Set<RecipientId> = DecryptedGroupUtil.removedPendingMembersUuidList(change).toRecipientIds().toSet()
val acceptedInvites: Set<RecipientId> = DecryptedGroupUtil.membersToUuidList(change.promotePendingMembersList).toRecipientIds().toSet()
unmigratedV1Members -= addedMembers
unmigratedV1Members -= removedMembers
unmigratedV1Members -= addedInvites
unmigratedV1Members -= removedInvites
unmigratedV1Members -= acceptedInvites
contentValues.put(UNMIGRATED_V1_MEMBERS, if (unmigratedV1Members.isEmpty()) null else unmigratedV1Members.serialize())
}
val groupMembers = getV2GroupMembers(decryptedGroup, true)
if (existingGroup.isPresent && existingGroup.get().isV2Group) {
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
val removed: List<UUID> = DecryptedGroupUtil.removedMembersUuidList(change)
if (removed.isNotEmpty()) {
val distributionId = existingGroup.get().distributionId!!
Log.i(TAG, removed.size.toString() + " members were removed from group " + groupId + ". Rotating the DistributionId " + distributionId)
SenderKeyUtil.rotateOurKey(distributionId)
}
}
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)
}
if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) {
recipients.setHasGroupsInCommon(groupMembers)
}
Recipient.live(groupRecipientId).refresh()
notifyConversationListListeners()
}
fun updateTitle(groupId: GroupId.V1, title: String?) {
updateTitle(groupId as GroupId, title)
}
fun updateTitle(groupId: GroupId.Mms, title: String?) {
updateTitle(groupId as GroupId, if (title.isNullOrEmpty()) null else title)
}
private fun updateTitle(groupId: GroupId, title: String?) {
if (!groupId.isV1 && !groupId.isMms) {
throw AssertionError()
}
writableDatabase
.update(TABLE_NAME)
.values(TITLE to title)
.where("$GROUP_ID = ?", groupId)
.run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh()
}
/**
* Used to bust the Glide cache when an avatar changes.
*/
fun onAvatarUpdated(groupId: GroupId, hasAvatar: Boolean) {
writableDatabase
.update(TABLE_NAME)
.values(AVATAR_ID to if (hasAvatar) Math.abs(SecureRandom().nextLong()) else 0)
.where("$GROUP_ID = ?", groupId)
.run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh()
}
fun updateMembers(groupId: GroupId, members: List<RecipientId>) {
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) {
writableDatabase
.delete(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, source)
.run()
val groupRecipient = recipients.getOrInsertFromGroupId(groupId)
Recipient.live(groupRecipient).refresh()
}
private fun getCurrentMembers(groupId: GroupId): MutableList<RecipientId> {
return readableDatabase
.select(MembershipTable.RECIPIENT_ID)
.from(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ?", groupId)
.run()
.readToList { cursor ->
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
}
fun setActive(groupId: GroupId, active: Boolean) {
writableDatabase
.update(TABLE_NAME)
.values(ACTIVE to if (active) 1 else 0)
.where("$GROUP_ID = ?", groupId)
.run()
}
fun setLastForceUpdateTimestamp(groupId: GroupId, timestamp: Long) {
writableDatabase
.update(TABLE_NAME)
.values(LAST_FORCE_UPDATE_TIMESTAMP to timestamp)
.where("$GROUP_ID = ?", groupId)
.run()
}
@WorkerThread
fun isCurrentMember(groupId: Push, recipientId: RecipientId): Boolean {
return readableDatabase
.exists(MembershipTable.TABLE_NAME)
.where("${MembershipTable.GROUP_ID} = ? AND ${MembershipTable.RECIPIENT_ID} = ?", groupId, recipientId)
.run()
}
fun getAllGroupV2Ids(): List<GroupId.V2> {
return readableDatabase
.select(GROUP_ID)
.from(TABLE_NAME)
.run()
.readToList { GroupId.parseOrThrow(it.requireNonNullString(GROUP_ID)) }
.filter { it.isV2 }
.map { it.requireV2() }
}
/**
* Key: The 'expected' V2 ID (i.e. what a V1 ID would map to when migrated)
* Value: The matching V1 ID
*/
fun getAllExpectedV2Ids(): Map<GroupId.V2, GroupId.V1> {
return readableDatabase
.select(GROUP_ID, EXPECTED_V2_ID)
.from(TABLE_NAME)
.where("$EXPECTED_V2_ID NOT NULL")
.run()
.readToList { cursor ->
val groupId = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)).requireV1()
val expectedId = GroupId.parseOrThrow(cursor.requireNonNullString(EXPECTED_V2_ID)).requireV2()
expectedId to groupId
}
.toMap()
}
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)) {
if (group.isV2Group) {
removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId))
}
}
}
class Reader(val cursor: Cursor?) : Closeable, ContactSearchIterator<GroupRecord> {
fun getNext(): GroupRecord? {
return if (cursor == null || !cursor.moveToNext()) {
null
} else {
getCurrent()
}
}
override fun getCount(): Int {
return cursor?.count ?: 0
}
fun getCurrent(): GroupRecord? {
return if (cursor == null || cursor.requireString(GROUP_ID) == null || cursor.requireLong(RECIPIENT_ID) == 0L) {
null
} else {
GroupRecord(
id = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)),
recipientId = RecipientId.from(cursor.requireNonNullString(RECIPIENT_ID)),
title = cursor.requireString(TITLE),
serializedMembers = cursor.requireString(MEMBER_GROUP_CONCAT),
serializedUnmigratedV1Members = cursor.requireString(UNMIGRATED_V1_MEMBERS),
avatarId = cursor.requireLong(AVATAR_ID),
avatarKey = cursor.requireBlob(AVATAR_KEY),
avatarContentType = cursor.requireString(AVATAR_CONTENT_TYPE),
relay = cursor.requireString(AVATAR_RELAY),
isActive = cursor.requireBoolean(ACTIVE),
avatarDigest = cursor.requireBlob(AVATAR_DIGEST),
isMms = cursor.requireBoolean(MMS),
groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY),
groupRevision = cursor.requireInt(V2_REVISION),
decryptedGroupBytes = cursor.requireBlob(V2_DECRYPTED_GROUP),
distributionId = cursor.optionalString(DISTRIBUTION_ID).map { id -> DistributionId.from(id) }.orElse(null),
lastForceUpdateTimestamp = cursor.requireLong(LAST_FORCE_UPDATE_TIMESTAMP)
)
}
}
override fun close() {
cursor?.close()
}
override fun moveToPosition(n: Int) {
cursor!!.moveToPosition(n)
}
override fun hasNext(): Boolean {
return cursor != null && !cursor.isLast && !cursor.isAfterLast
}
override fun next(): GroupRecord {
return getNext()!!
}
}
class V2GroupProperties(val groupMasterKey: GroupMasterKey, val groupRevision: Int, val decryptedGroupBytes: ByteArray) {
val decryptedGroup: DecryptedGroup by lazy {
DecryptedGroup.parseFrom(decryptedGroupBytes)
}
val bannedMembers: Set<UUID> by lazy {
DecryptedGroupUtil.bannedMembersToUuidSet(decryptedGroup.bannedMembersList)
}
fun isAdmin(recipient: Recipient): Boolean {
val serviceId = recipient.serviceId
return if (serviceId.isPresent) {
DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, serviceId.get().uuid())
.map { it.role == Member.Role.ADMINISTRATOR }
.orElse(false)
} else {
false
}
}
fun getAdmins(members: List<Recipient>): List<Recipient> {
return members.stream().filter { recipient: Recipient -> isAdmin(recipient) }.collect(Collectors.toList())
}
fun memberLevel(serviceId: Optional<ServiceId>): MemberLevel {
if (!serviceId.isPresent) {
return MemberLevel.NOT_A_MEMBER
}
var memberLevel: Optional<MemberLevel> = DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, serviceId.get().uuid())
.map { member ->
if (member.role == Member.Role.ADMINISTRATOR) {
MemberLevel.ADMINISTRATOR
} else {
MemberLevel.FULL_MEMBER
}
}
if (memberLevel.isAbsent()) {
memberLevel = DecryptedGroupUtil.findPendingByUuid(decryptedGroup.pendingMembersList, serviceId.get().uuid())
.map { MemberLevel.PENDING_MEMBER }
}
if (memberLevel.isAbsent()) {
memberLevel = DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.requestingMembersList, serviceId.get().uuid())
.map { _ -> MemberLevel.REQUESTING_MEMBER }
}
return if (memberLevel.isPresent) {
memberLevel.get()
} else {
MemberLevel.NOT_A_MEMBER
}
}
fun getMemberRecipients(memberSet: MemberSet): List<Recipient> {
return Recipient.resolvedList(getMemberRecipientIds(memberSet))
}
fun getMemberRecipientIds(memberSet: MemberSet): List<RecipientId> {
val includeSelf = memberSet.includeSelf
val selfUuid = SignalStore.account().requireAci().uuid()
val recipients: MutableList<RecipientId> = ArrayList(decryptedGroup.membersCount + decryptedGroup.pendingMembersCount)
var unknownMembers = 0
var unknownPending = 0
for (uuid in DecryptedGroupUtil.toUuidList(decryptedGroup.membersList)) {
if (UuidUtil.UNKNOWN_UUID == uuid) {
unknownMembers++
} else if (includeSelf || selfUuid != uuid) {
recipients += RecipientId.from(ServiceId.from(uuid))
}
}
if (memberSet.includePending) {
for (uuid in DecryptedGroupUtil.pendingToUuidList(decryptedGroup.pendingMembersList)) {
if (UuidUtil.UNKNOWN_UUID == uuid) {
unknownPending++
} else if (includeSelf || selfUuid != uuid) {
recipients += RecipientId.from(ServiceId.from(uuid))
}
}
}
if (unknownMembers + unknownPending > 0) {
Log.w(TAG, "Group contains $unknownPending unknown pending and $unknownMembers unknown full members")
}
return recipients
}
}
@Throws(BadGroupIdException::class)
fun getGroupsToDisplayAsStories(): List<GroupId> {
@Language("sql")
val query = """
SELECT
$GROUP_ID,
(
SELECT ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}
FROM ${MessageTable.TABLE_NAME}
WHERE
${MessageTable.TABLE_NAME}.${MessageTable.RECIPIENT_ID} = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AND
${MessageTable.STORY_TYPE} > 1
ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC
LIMIT 1
) AS active_timestamp
FROM $TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE
$ACTIVE = 1 AND
(
$SHOW_AS_STORY_STATE = ${ShowAsStoryState.ALWAYS.code} OR
(
$SHOW_AS_STORY_STATE = ${ShowAsStoryState.IF_ACTIVE.code} AND
active_timestamp IS NOT NULL
)
)
ORDER BY active_timestamp DESC
""".toSingleLine()
return readableDatabase
.query(query)
.readToList { cursor ->
GroupId.parse(cursor.requireNonNullString(GROUP_ID))
}
}
fun getShowAsStoryState(groupId: GroupId): ShowAsStoryState {
return readableDatabase
.select(SHOW_AS_STORY_STATE)
.from(TABLE_NAME)
.where("$GROUP_ID = ?", groupId)
.run()
.readToSingleObject { cursor ->
val serializedState = cursor.requireInt(SHOW_AS_STORY_STATE)
ShowAsStoryState.deserialize(serializedState)
} ?: throw AssertionError("Group $groupId does not exist!")
}
fun setShowAsStoryState(groupId: GroupId, showAsStoryState: ShowAsStoryState) {
writableDatabase
.update(TABLE_NAME)
.values(SHOW_AS_STORY_STATE to showAsStoryState.code)
.where("$GROUP_ID = ?", groupId)
.run()
}
fun setShowAsStoryState(recipientIds: Collection<RecipientId?>, showAsStoryState: ShowAsStoryState) {
val queries = buildCollectionQuery(RECIPIENT_ID, recipientIds)
val contentValues = contentValuesOf(SHOW_AS_STORY_STATE to showAsStoryState.code)
writableDatabase.withinTransaction { db ->
for (query in queries) {
db.update(TABLE_NAME, contentValues, query.where, query.whereArgs)
}
}
}
private fun gv2GroupActive(decryptedGroup: DecryptedGroup): Boolean {
val aci = SignalStore.account().requireAci()
return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, aci.uuid()).isPresent ||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.pendingMembersList, aci.uuid()).isPresent
}
private fun List<UUID>.toRecipientIds(): MutableList<RecipientId> {
return uuidsToRecipientIds(this)
}
private fun Collection<RecipientId>.serialize(): String {
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()
.map { uuid ->
if (uuid == UuidUtil.UNKNOWN_UUID) {
Log.w(TAG, "Saw an unknown UUID when mapping to RecipientIds!")
null
} else {
val id = RecipientId.from(ServiceId.from(uuid))
val remapped = RemappedRecords.getInstance().getRecipient(id)
if (remapped.isPresent) {
Log.w(TAG, "Saw that $id remapped to $remapped. Using the mapping.")
remapped.get()
} else {
id
}
}
}
.filterNotNull()
.sorted()
.toMutableList()
}
private fun getV2GroupMembers(decryptedGroup: DecryptedGroup, shouldRetry: Boolean): List<RecipientId> {
val uuids: List<UUID> = DecryptedGroupUtil.membersToUuidList(decryptedGroup.membersList)
val ids: List<RecipientId> = uuidsToRecipientIds(uuids)
return if (RemappedRecords.getInstance().areAnyRemapped(ids)) {
if (shouldRetry) {
Log.w(TAG, "Found remapped records where we shouldn't. Clearing cache and trying again.")
RecipientId.clearCache()
RemappedRecords.getInstance().resetCache()
getV2GroupMembers(decryptedGroup, false)
} else {
throw IllegalStateException("Remapped records in group membership!")
}
} else {
ids
}
}
enum class MemberSet(val includeSelf: Boolean, val includePending: Boolean) {
FULL_MEMBERS_INCLUDING_SELF(true, false), FULL_MEMBERS_EXCLUDING_SELF(false, false), FULL_MEMBERS_AND_PENDING_INCLUDING_SELF(true, true), FULL_MEMBERS_AND_PENDING_EXCLUDING_SELF(false, true);
}
/**
* State object describing whether or not to display a story in a list.
*/
enum class ShowAsStoryState(val code: Int) {
/**
* The default value. Display the group as a story if the group has stories in it currently.
*/
IF_ACTIVE(0),
/**
* Always display the group as a story unless explicitly removed. This state is entered if the
* user sends a story to a group or otherwise explicitly selects it to appear.
*/
ALWAYS(1),
/**
* Never display the story as a group. This state is entered if the user removes the group from
* their list, and is only navigated away from if the user explicitly adds the group again.
*/
NEVER(2);
companion object {
fun deserialize(code: Int): ShowAsStoryState {
return when (code) {
0 -> IF_ACTIVE
1 -> ALWAYS
2 -> NEVER
else -> throw IllegalArgumentException("Unknown code: $code")
}
}
}
}
enum class MemberLevel(val isInGroup: Boolean) {
NOT_A_MEMBER(false),
PENDING_MEMBER(false),
REQUESTING_MEMBER(false),
FULL_MEMBER(true),
ADMINISTRATOR(true)
}
class GroupQuery private constructor(builder: Builder) {
val searchQuery: String
val includeInactive: Boolean
val includeV1: Boolean
val includeMms: Boolean
val sortOrder: ContactSearchSortOrder
init {
searchQuery = builder.searchQuery
includeInactive = builder.includeInactive
includeV1 = builder.includeV1
includeMms = builder.includeMms
sortOrder = builder.sortOrder
}
class Builder {
var searchQuery = ""
var includeInactive = false
var includeV1 = false
var includeMms = false
var sortOrder = ContactSearchSortOrder.NATURAL
fun withSearchQuery(query: String?): Builder {
searchQuery = if (TextUtils.isEmpty(query)) "" else query!!
return this
}
fun withInactiveGroups(includeInactive: Boolean): Builder {
this.includeInactive = includeInactive
return this
}
fun withV1Groups(includeV1Groups: Boolean): Builder {
includeV1 = includeV1Groups
return this
}
fun withMmsGroups(includeMmsGroups: Boolean): Builder {
includeMms = includeMmsGroups
return this
}
fun withSortOrder(sortOrder: ContactSearchSortOrder): Builder {
this.sortOrder = sortOrder
return this
}
fun build(): GroupQuery {
return GroupQuery(this)
}
}
}
class LegacyGroupInsertException(id: GroupId?) : IllegalStateException("Tried to create a new GV1 entry when we already had a migrated GV2! $id")
class MissedGroupMigrationInsertException(id: GroupId?) : IllegalStateException("Tried to create a new GV2 entry when we already had a V1 group that mapped to the new ID! $id")
}