Add group terminate support.

This commit is contained in:
Cody Henthorne
2026-03-19 16:10:26 -04:00
parent 0896718e5c
commit a0c0acb8fc
130 changed files with 1312 additions and 146 deletions
@@ -34,7 +34,6 @@ 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.toInt
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
@@ -103,7 +102,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
const val AVATAR_CONTENT_TYPE = "avatar_content_type"
const val AVATAR_DIGEST = "avatar_digest"
const val TIMESTAMP = "timestamp"
const val ACTIVE = "active"
const val IS_MEMBER = "active"
const val TERMINATED_BY = "terminated_by"
const val MMS = "mms"
const val EXPECTED_V2_ID = "expected_v2_id"
const val UNMIGRATED_V1_MEMBERS = "unmigrated_v1_members"
@@ -133,17 +133,18 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
$AVATAR_CONTENT_TYPE TEXT DEFAULT NULL,
$AVATAR_DIGEST BLOB DEFAULT NULL,
$TIMESTAMP INTEGER DEFAULT 0,
$ACTIVE INTEGER DEFAULT 1,
$MMS INTEGER DEFAULT 0,
$V2_MASTER_KEY BLOB DEFAULT NULL,
$V2_REVISION BLOB DEFAULT NULL,
$V2_DECRYPTED_GROUP BLOB DEFAULT NULL,
$EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL,
$UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL,
$DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL,
$SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code},
$IS_MEMBER INTEGER DEFAULT 1,
$MMS INTEGER DEFAULT 0,
$V2_MASTER_KEY BLOB DEFAULT NULL,
$V2_REVISION BLOB DEFAULT NULL,
$V2_DECRYPTED_GROUP BLOB DEFAULT NULL,
$EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL,
$UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL,
$DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL,
$SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code},
$LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0,
$GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0
$GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0,
$TERMINATED_BY INTEGER DEFAULT 0
)
"""
@@ -160,7 +161,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
AVATAR_CONTENT_TYPE,
AVATAR_DIGEST,
TIMESTAMP,
ACTIVE,
IS_MEMBER,
TERMINATED_BY,
MMS,
V2_MASTER_KEY,
V2_REVISION,
@@ -348,7 +350,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
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 $TABLE_NAME.$ACTIVE = 1 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
WHERE $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
"""
@@ -404,9 +406,9 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
query = if (includeInactive) {
"($searchQuery) AND ($TABLE_NAME.$ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))"
"($searchQuery) AND ($TABLE_NAME.$IS_MEMBER = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))"
} else {
"($searchQuery) AND $TABLE_NAME.$ACTIVE = ?"
"($searchQuery) AND $TABLE_NAME.$IS_MEMBER = ? AND $TABLE_NAME.$TERMINATED_BY = 0"
}
queryArgs = buildArgs(*searchTokens.toTypedArray(), 1)
@@ -495,8 +497,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
if (!includeInactive) {
query += " AND $TABLE_NAME.$ACTIVE = ?"
query += " AND $TABLE_NAME.$IS_MEMBER = ?"
args = appendArg(args, "1")
query += " AND $TABLE_NAME.$TERMINATED_BY = ?"
args = appendArg(args, "0")
}
return readableDatabase
@@ -522,22 +527,23 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
return Reader(cursor)
}
fun getInactiveGroups(): Reader {
val query = SqlUtil.buildQuery("$TABLE_NAME.$ACTIVE = ?", false.toInt())
val select = "${joinedGroupSelect()} WHERE ${query.where}"
return Reader(readableDatabase.query(select, query.whereArgs))
}
fun getActiveGroupCount(): Int {
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = ?", 1)
.where("$IS_MEMBER = ? AND $TERMINATED_BY = ?", 1, 0)
.run()
.readToSingleInt(0)
}
fun setTerminatedBy(groupId: GroupId, recipientId: RecipientId) {
writableDatabase
.update(TABLE_NAME)
.values(TERMINATED_BY to recipientId.serialize())
.where("$GROUP_ID = ?", groupId)
.run()
}
@WorkerThread
fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List<RecipientId> {
return if (groupId.isV2) {
@@ -688,14 +694,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
values.put(TIMESTAMP, System.currentTimeMillis())
if (groupId.isV2) {
values.put(ACTIVE, if (groupState != null && gv2GroupActive(groupState)) 1 else 0)
values.put(IS_MEMBER, if (groupState != null && isGroupMember(groupState)) 1 else 0)
values.put(TERMINATED_BY, if (groupState?.terminated == true) -1 else 0)
values.put(DISTRIBUTION_ID, DistributionId.create().toString())
values.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements?.expirationMs ?: 0)
} else if (groupId.isV1) {
values.put(ACTIVE, 1)
values.put(IS_MEMBER, 1)
values.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString())
} else {
values.put(ACTIVE, 1)
values.put(IS_MEMBER, 1)
}
if (groupMasterKey != null) {
@@ -793,7 +800,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
contentValues.put(TITLE, title)
contentValues.put(V2_REVISION, decryptedGroup.revision)
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.encode())
contentValues.put(ACTIVE, if (gv2GroupActive(decryptedGroup)) 1 else 0)
contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0)
contentValues.put(TERMINATED_BY, if (decryptedGroup.terminated) -1 else 0)
if (receivedGroupSendEndorsements != null) {
contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs)
@@ -938,10 +946,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
return record.isPresent && record.get().isActive
}
fun setActive(groupId: GroupId, active: Boolean) {
fun isMember(groupId: GroupId): Boolean {
val record = getGroup(groupId)
return record.isPresent && record.get().isMember
}
fun setMember(groupId: GroupId, isMember: Boolean) {
writableDatabase
.update(TABLE_NAME)
.values(ACTIVE to if (active) 1 else 0)
.values(IS_MEMBER to if (isMember) 1 else 0)
.where("$GROUP_ID = ?", groupId)
.run()
}
@@ -1079,6 +1092,12 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
writableDatabase
.update(TABLE_NAME)
.values(TERMINATED_BY to toId.toLong())
.where("$TERMINATED_BY = ?", fromId.toLong())
.run()
// Remap all recipients that would not result in conflicts
writableDatabase.execSQL(
"""
@@ -1139,7 +1158,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
avatarId = cursor.requireLong(AVATAR_ID),
avatarKey = cursor.requireBlob(AVATAR_KEY),
avatarContentType = cursor.requireString(AVATAR_CONTENT_TYPE),
isActive = cursor.requireBoolean(ACTIVE),
isMember = cursor.requireBoolean(IS_MEMBER),
terminatedBy = cursor.requireLong(TERMINATED_BY),
avatarDigest = cursor.requireBlob(AVATAR_DIGEST),
isMms = cursor.requireBoolean(MMS),
groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY),
@@ -1345,7 +1365,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
) AS active_timestamp
FROM $TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE
$TABLE_NAME.$ACTIVE = 1 AND
$TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND
(
$SHOW_AS_STORY_STATE = ${ShowAsStoryState.ALWAYS.code} OR
(
@@ -1394,7 +1414,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
}
private fun gv2GroupActive(decryptedGroup: DecryptedGroup): Boolean {
private fun isGroupMember(decryptedGroup: DecryptedGroup): Boolean {
val aci = SignalStore.account.requireAci()
return decryptedGroup.members.findMemberByAci(aci).isPresent ||
@@ -2861,7 +2861,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isGroupAdd) ||
val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isNotifiable) ||
retrieved.type == MessageType.IDENTITY_DEFAULT ||
retrieved.type == MessageType.IDENTITY_VERIFIED ||
retrieved.type == MessageType.IDENTITY_UPDATE
@@ -3745,7 +3745,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val threadDatabase = threads
val recipientsWithinInteractionThreshold: MutableSet<RecipientId> = LinkedHashSet()
threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false)).use { reader ->
threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1)).use { reader ->
var record: ThreadRecord? = reader.getNext()
while (record != null && record.date > lastInteractionThreshold) {
@@ -4830,7 +4830,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT 1
FROM ${GroupTable.MembershipTable.TABLE_NAME}
INNER JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} = ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.GROUP_ID}
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
)
"""
val E164_SEARCH = "(($PHONE_NUMBER_SHARING != ${PhoneNumberSharingState.DISABLED.id} OR $SYSTEM_CONTACT_URI NOT NULL) AND $E164 GLOB ?)"
@@ -873,7 +873,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
var where = ""
if (!includeInactiveGroups) {
where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} IS NULL OR ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1)"
where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} IS NULL OR (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0))"
} else {
where += "$MEANINGFUL_MESSAGES != 0"
}
@@ -922,8 +922,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return readableDatabase.rawQuery(query, null)
}
fun getRecentPushConversationList(limit: Int, includeInactiveGroups: Boolean): Cursor {
val activeGroupQuery = if (!includeInactiveGroups) " AND " + GroupTable.TABLE_NAME + "." + GroupTable.ACTIVE + " = 1" else ""
fun getRecentPushConversationList(limit: Int): Cursor {
val where = """
$MEANINGFUL_MESSAGES != 0
AND (
@@ -931,7 +930,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
OR (
${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} NOT NULL
AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
$activeGroupQuery
AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1
AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0
)
)
"""
@@ -161,6 +161,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNo
import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminatedColumnMigration
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -329,10 +330,11 @@ object SignalDatabaseMigrations {
305 to V305_AddStoryArchivedColumn,
306 to V306_AddRemoteDeletedColumn,
// 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future
308 to V308_AddBackRemoteDeletedColumn
308 to V308_AddBackRemoteDeletedColumn,
309 to V309_GroupTerminatedColumnMigration
)
const val DATABASE_VERSION = 308
const val DATABASE_VERSION = 309
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds 'terminated_by' that stores the recipient id of the
* admin who terminated the group, -1 if unknown, 0 if not terminated.
*/
@Suppress("ClassName")
object V309_GroupTerminatedColumnMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE groups ADD COLUMN terminated_by INTEGER DEFAULT 0")
}
}
@@ -24,7 +24,8 @@ class GroupRecord(
val avatarId: Long,
val avatarKey: ByteArray?,
val avatarContentType: String?,
val isActive: Boolean,
val isMember: Boolean,
private val terminatedBy: Long = 0,
val avatarDigest: ByteArray?,
val isMms: Boolean,
groupMasterKeyBytes: ByteArray?,
@@ -61,6 +62,15 @@ class GroupRecord(
}
}
val isTerminated: Boolean
get() = terminatedBy != 0L
val terminatedByRecipientId: RecipientId?
get() = if (terminatedBy > 0) RecipientId.from(terminatedBy) else null
val isActive: Boolean
get() = isMember && !isTerminated
val description: String
get() = v2GroupProperties?.decryptedGroup?.description ?: ""
@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChan
import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupSequenceOfRequestsAndCancelsUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel
import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedOtherUserToGroupUpdate
@@ -155,6 +156,7 @@ object GroupsV2UpdateMessageConverter {
translateAnnouncementGroupChange(change, editorUnknown, updates)
translatePromotePendingPniAci(selfIds, change, editorUnknown, updates)
translateMemberRemovals(selfIds, change, editorUnknown, updates)
translateTerminateGroup(change, editorUnknown, updates)
if (updates.isEmpty()) {
translateUnknownChange(change, editorUnknown, updates)
}
@@ -684,6 +686,20 @@ object GroupsV2UpdateMessageConverter {
}
}
@JvmStatic
fun translateTerminateGroup(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList<GroupChangeChatUpdate.Update>) {
if (change.terminateGroup) {
val editorAci = if (editorUnknown) null else change.editorServiceIdBytes
updates.add(
GroupChangeChatUpdate.Update(
groupTerminateChangeUpdate = GroupTerminateChangeUpdate(
updaterAci = editorAci
)
)
)
}
}
@JvmStatic
fun translateUnknownChange(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList<GroupChangeChatUpdate.Update>) {
updates.add(
@@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberRemovedUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChangeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel;
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationDroppedMembersUpdate;
@@ -220,6 +221,8 @@ final class GroupsV2UpdateMessageProducer {
describeGroupExpirationTimerUpdate(update.groupExpirationTimerUpdate, updates);
} else if (update.groupSelfInvitationRevokedUpdate != null) {
describeGroupSelfInvitationRevokedUpdate(update.groupSelfInvitationRevokedUpdate, updates);
} else if (update.groupTerminateChangeUpdate != null) {
describeGroupTerminateUpdate(update.groupTerminateChangeUpdate, updates);
}
}
@@ -231,6 +234,18 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeGroupTerminateUpdate(@NonNull GroupTerminateChangeUpdate update, @NonNull List<UpdateDescription> updates) {
if (update.updaterAci == null) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_terminated), Glyph.X_CIRCLE));
} else {
if (selfIds.matches(update.updaterAci)) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_terminated_the_group), Glyph.X_CIRCLE));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_terminated_the_group, update.updaterAci, Glyph.X_CIRCLE));
}
}
}
private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List<UpdateDescription> updates) {
final int duration = Math.toIntExact(update.expiresInMs / 1000);
String time = ExpirationUtil.getExpirationDisplayValue(context, duration);