diff --git a/app/src/androidTest/assets/backupTests/account_data_00.binproto b/app/src/androidTest/assets/backupTests/account_data_00.binproto index 9f3ca718a3..3186ce9747 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_00.binproto and b/app/src/androidTest/assets/backupTests/account_data_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_01.binproto b/app/src/androidTest/assets/backupTests/account_data_01.binproto index 4c59bbfb41..3f1b5a5066 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_01.binproto and b/app/src/androidTest/assets/backupTests/account_data_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_02.binproto b/app/src/androidTest/assets/backupTests/account_data_02.binproto index d33e90f579..1261027d24 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_02.binproto and b/app/src/androidTest/assets/backupTests/account_data_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_03.binproto b/app/src/androidTest/assets/backupTests/account_data_03.binproto index d16c38c972..724edefba1 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_03.binproto and b/app/src/androidTest/assets/backupTests/account_data_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_04.binproto b/app/src/androidTest/assets/backupTests/account_data_04.binproto index 1c105ab3d0..0d673310e0 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_04.binproto and b/app/src/androidTest/assets/backupTests/account_data_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_05.binproto b/app/src/androidTest/assets/backupTests/account_data_05.binproto index d1b15898f0..8fbce2f249 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_05.binproto and b/app/src/androidTest/assets/backupTests/account_data_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_06.binproto b/app/src/androidTest/assets/backupTests/account_data_06.binproto index 1780989e6e..3bcc7f46af 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_06.binproto and b/app/src/androidTest/assets/backupTests/account_data_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_07.binproto b/app/src/androidTest/assets/backupTests/account_data_07.binproto index d0f2e351fe..543723762e 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_07.binproto and b/app/src/androidTest/assets/backupTests/account_data_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_08.binproto b/app/src/androidTest/assets/backupTests/account_data_08.binproto index aeffa78d3f..3372a0cca9 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_08.binproto and b/app/src/androidTest/assets/backupTests/account_data_08.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_09.binproto b/app/src/androidTest/assets/backupTests/account_data_09.binproto index 236bbbb91b..aba744ef9e 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_09.binproto and b/app/src/androidTest/assets/backupTests/account_data_09.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_10.binproto b/app/src/androidTest/assets/backupTests/account_data_10.binproto index 990e8a1656..a1ba413e98 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_10.binproto and b/app/src/androidTest/assets/backupTests/account_data_10.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_11.binproto b/app/src/androidTest/assets/backupTests/account_data_11.binproto index 1a89d507fb..3204db3c3a 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_11.binproto and b/app/src/androidTest/assets/backupTests/account_data_11.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_12.binproto b/app/src/androidTest/assets/backupTests/account_data_12.binproto index 0fbe208c51..0755e01987 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_12.binproto and b/app/src/androidTest/assets/backupTests/account_data_12.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_13.binproto b/app/src/androidTest/assets/backupTests/account_data_13.binproto index 6b28bf4086..be5e0b63d3 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_13.binproto and b/app/src/androidTest/assets/backupTests/account_data_13.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_14.binproto b/app/src/androidTest/assets/backupTests/account_data_14.binproto index dfa24fa270..13cf9bc999 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_14.binproto and b/app/src/androidTest/assets/backupTests/account_data_14.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_15.binproto b/app/src/androidTest/assets/backupTests/account_data_15.binproto index d7cf688612..1f94e2f822 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_15.binproto and b/app/src/androidTest/assets/backupTests/account_data_15.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_16.binproto b/app/src/androidTest/assets/backupTests/account_data_16.binproto index 893acc65ab..9dd3dbef3a 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_16.binproto and b/app/src/androidTest/assets/backupTests/account_data_16.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_17.binproto b/app/src/androidTest/assets/backupTests/account_data_17.binproto index 7e89e1addb..190a40715d 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_17.binproto and b/app/src/androidTest/assets/backupTests/account_data_17.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_18.binproto b/app/src/androidTest/assets/backupTests/account_data_18.binproto index c62554ed61..d1ec48ed14 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_18.binproto and b/app/src/androidTest/assets/backupTests/account_data_18.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_19.binproto b/app/src/androidTest/assets/backupTests/account_data_19.binproto index 0818c93931..2320be5aef 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_19.binproto and b/app/src/androidTest/assets/backupTests/account_data_19.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_20.binproto b/app/src/androidTest/assets/backupTests/account_data_20.binproto index 2ebcf9db93..35ef6edbd8 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_20.binproto and b/app/src/androidTest/assets/backupTests/account_data_20.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_21.binproto b/app/src/androidTest/assets/backupTests/account_data_21.binproto index b3593e9521..8dd00d3677 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_21.binproto and b/app/src/androidTest/assets/backupTests/account_data_21.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_22.binproto b/app/src/androidTest/assets/backupTests/account_data_22.binproto index 1f1f2bac6b..7b36b9ef89 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_22.binproto and b/app/src/androidTest/assets/backupTests/account_data_22.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_23.binproto b/app/src/androidTest/assets/backupTests/account_data_23.binproto index 1736c477c0..04c82c11a8 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_23.binproto and b/app/src/androidTest/assets/backupTests/account_data_23.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_24.binproto b/app/src/androidTest/assets/backupTests/account_data_24.binproto index 73e07d057a..1e18c74a71 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_24.binproto and b/app/src/androidTest/assets/backupTests/account_data_24.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_25.binproto b/app/src/androidTest/assets/backupTests/account_data_25.binproto index c1f9dd80a2..2a331b84a3 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_25.binproto and b/app/src/androidTest/assets/backupTests/account_data_25.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_26.binproto b/app/src/androidTest/assets/backupTests/account_data_26.binproto index d34b2eece8..8d8c28b33e 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_26.binproto and b/app/src/androidTest/assets/backupTests/account_data_26.binproto differ diff --git a/app/src/androidTest/assets/backupTests/account_data_27.binproto b/app/src/androidTest/assets/backupTests/account_data_27.binproto index 699a64a2ac..8208d46c48 100644 Binary files a/app/src/androidTest/assets/backupTests/account_data_27.binproto and b/app/src/androidTest/assets/backupTests/account_data_27.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto index 68a5e5046a..55b9b206f3 100644 Binary files a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_16.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_17.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_17.binproto new file mode 100644 index 0000000000..d9e3e00788 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_multiple_update_17.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto new file mode 100644 index 0000000000..a087ce288d Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_79.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto new file mode 100644 index 0000000000..4a3e9d2957 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_80.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto new file mode 100644 index 0000000000..21dc7f1e68 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_81.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto new file mode 100644 index 0000000000..61b080f35d Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_82.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto new file mode 100644 index 0000000000..eca84dc947 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_83.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto new file mode 100644 index 0000000000..4bbe26616c Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_group_change_chat_update_84.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto index 132937538a..5992a98f75 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto index 8443932d98..b4d6196ed7 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto index d5a76db9f7..34d9bf4c18 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_02.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto index 3f13df046b..f60d6a6b14 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_03.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto index 9b1b05dea4..328309681b 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_04.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto index fbb961679e..400f3a99c1 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_05.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto index ac1b5ec1c3..382362896e 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_06.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto index 5f80a65756..1f3d18cf51 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_07.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto index 9ad149e775..e31ee5c5a5 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_08.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto index 0ab9d061be..a354fc6914 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_09.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto index 2612c661fe..08d5acee4e 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_10.binproto differ diff --git a/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto b/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto index 4121043083..3018becfc6 100644 Binary files a/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto and b/app/src/androidTest/assets/backupTests/recipient_groups_11.binproto differ diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt index 76ca569ece..40ea81f97f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt @@ -313,7 +313,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible { timestamp = wallClock, groupId = groupId, update = updateDescription, - isGroupAdd = false, + isNotifiable = false, serverGuid = null ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java index f524d1d36a..814783a057 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java @@ -76,7 +76,7 @@ public final class BlockUnblockDialog { Resources resources = context.getResources(); if (recipient.isGroup()) { - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context))); builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates); builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run())); @@ -121,7 +121,7 @@ public final class BlockUnblockDialog { Resources resources = context.getResources(); if (recipient.isGroup()) { - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context))); builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you); builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt index 50be3f418e..4e93ca8195 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableArchiveExtensions.kt @@ -97,7 +97,7 @@ fun RecipientTable.getGroupsForBackup(selfAci: ServiceId.ACI): GroupArchiveExpor "${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}", "${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}", "${GroupTable.TABLE_NAME}.${GroupTable.TITLE}", - "${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE}", + "${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER}", "${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}" ) .from( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt index bd5a19f42c..343ac27246 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExporter.kt @@ -50,7 +50,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso val extras = RecipientTableCursorUtil.getExtras(cursor) val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE)) - val isActive: Boolean = cursor.requireBoolean(GroupTable.ACTIVE) + val isMember: Boolean = cursor.requireBoolean(GroupTable.IS_MEMBER) val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!) return ArchiveRecipient( @@ -61,7 +61,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso blocked = cursor.requireBoolean(RecipientTable.BLOCKED), hideStory = extras?.hideStory() ?: false, storySendMode = showAsStoryState.toRemote(), - snapshot = decryptedGroup.toRemote(isActive, selfAci), + snapshot = decryptedGroup.toRemote(isMember, selfAci), avatarColor = cursor.requireString(RecipientTable.AVATAR_COLOR)?.let { AvatarColor.deserialize(it) }?.toRemote() ) ) @@ -80,9 +80,9 @@ private fun GroupTable.ShowAsStoryState.toRemote(): Group.StorySendMode { } } -private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? { +private fun DecryptedGroup.toRemote(isMember: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? { val selfAciBytes = selfAci.toByteString() - val memberFilter = { m: DecryptedMember -> isActive || m.aciBytes != selfAciBytes } + val memberFilter = { m: DecryptedMember -> isMember || m.aciBytes != selfAciBytes } return Group.GroupSnapshot( title = Group.GroupAttributeBlob(title = this.title), @@ -96,7 +96,8 @@ private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI): inviteLinkPassword = this.inviteLinkPassword, description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) }, announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED, - members_banned = this.bannedMembers.map { it.toRemote() } + members_banned = this.bannedMembers.map { it.toRemote() }, + terminated = this.terminated ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt index e8ce59f822..d5c3b557bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/GroupArchiveImporter.kt @@ -162,6 +162,7 @@ private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOper description = this.description?.descriptionText ?: "", isAnnouncementGroup = if (this.announcements_only) EnabledState.ENABLED else EnabledState.DISABLED, bannedMembers = this.members_banned.map { it.toLocal() }, + terminated = this.terminated, isPlaceholderGroup = isPlaceholder ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index d829d4a4ce..818082d291 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -124,7 +124,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements if (resolvedRecipient.isPresent() && resolvedRecipient.get().isGroup()) { Recipient recipient = resolvedRecipient.get(); - if (SignalDatabase.groups().isActive(recipient.requireGroupId())) { + if (SignalDatabase.groups().isMember(recipient.requireGroupId())) { builder.setTitle(getString(R.string.BlockUnblockDialog_block_and_leave_s, displayName)); builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index d131980e60..2315ac4f49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -71,7 +71,11 @@ class CallLogContextMenu( ) } - private fun getVideoCallActionItem(peer: Recipient): ActionItem { + private fun getVideoCallActionItem(peer: Recipient): ActionItem? { + if (peer.isGroup && !peer.isActiveGroup) { + return null + } + // TODO [alex] -- Need group calling disposition to make this correct return ActionItem( iconRes = R.drawable.symbol_video_24, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 126ea7d3ba..4b54231611 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -20,6 +20,7 @@ import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.view.doOnPreDraw import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.Navigation import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager @@ -28,6 +29,7 @@ import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.launch import org.signal.core.ui.permissions.Permissions import org.signal.core.util.DimensionUnit import org.signal.core.util.Result @@ -48,6 +50,7 @@ import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment @@ -67,6 +70,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference +import org.thoughtcrime.securesms.components.settings.conversation.preferences.TerminatedBannerPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.colors.ColorizerV2 @@ -74,6 +78,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.groups.ui.EndGroupDialog import org.thoughtcrime.securesms.groups.ui.GroupErrors import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry @@ -147,6 +152,12 @@ class ConversationSettingsFragment : } } + private val endGroupIcon by lazy { + ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply { + colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) + } + } + private val viewModel by viewModels( factoryProducer = { val groupId = args.groupId as? GroupId @@ -268,6 +279,7 @@ class ConversationSettingsFragment : InternalPreference.register(adapter) GroupDescriptionPreference.register(adapter) LegacyGroupPreference.register(adapter) + TerminatedBannerPreference.register(adapter) CallPreference.register(adapter) val recipientId = args.recipientId @@ -330,10 +342,17 @@ class ConversationSettingsFragment : return@configure } + state.withGroupSettingsState { + if (it.isTerminated) { + customPref(TerminatedBannerPreference.Model()) + } + } + customPref( AvatarPreference.Model( recipient = state.recipient, storyViewState = state.storyViewState, + reduceTopMargin = state.isTerminatedGroup, onAvatarClick = { avatar -> val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), state.recipient.id) val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar) @@ -392,7 +411,7 @@ class ConversationSettingsFragment : ) ) - if (groupState.groupId.isV2) { + if (groupState.groupId.isV2 && !groupState.isTerminated) { customPref( GroupDescriptionPreference.Model( groupId = groupState.groupId, @@ -793,10 +812,15 @@ class ConversationSettingsFragment : if (groupState.canAddToGroup || memberCount > 0) { dividerPref() - sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount))) + val memberHeaderText = if (groupState.isTerminated) { + resources.getQuantityString(R.plurals.ConversationSettingsFragment__d_former_members, memberCount, memberCount) + } else { + resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount) + } + sectionHeaderPref(DSLSettingsText.from(memberHeaderText)) } - if (groupState.canAddToGroup && !state.isDeprecatedOrUnregistered) { + if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) { customPref( LargeIconClickPreference.Model( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members), @@ -851,14 +875,14 @@ class ConversationSettingsFragment : ) } - if (state.recipient.isPushV2Group) { + if (state.recipient.isPushV2Group && !groupState.isTerminated) { dividerPref() clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link), summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off), icon = DSLSettingsIcon.from(R.drawable.ic_link_16), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString())) } @@ -880,7 +904,7 @@ class ConversationSettingsFragment : clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites), icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2())) } @@ -890,7 +914,7 @@ class ConversationSettingsFragment : clickPref( title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions), icon = DSLSettingsIcon.from(R.drawable.ic_lock_24), - isEnabled = !state.isDeprecatedOrUnregistered, + isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, onClick = { val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId) navController.safeNavigate(action) @@ -913,7 +937,46 @@ class ConversationSettingsFragment : } } - if (state.canModifyBlockedState) { + state.withGroupSettingsState { groupState -> + if (groupState.isTerminated) { + dividerPref() + + if (state.isArchived) { + clickPref( + title = DSLSettingsText.from(R.string.ConversationListFragment_unarchive), + icon = DSLSettingsIcon.from(R.drawable.symbol_archive_up_24), + onClick = { + viewModel.toggleArchive() + } + ) + } else { + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__archive_chat), + icon = DSLSettingsIcon.from(R.drawable.symbol_archive_24), + onClick = { + viewModel.toggleArchive() + onToolbarNavigationClicked() + } + ) + } + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__delete_chat, alertTint), + icon = DSLSettingsIcon.from(CoreUiR.drawable.symbol_trash_24, R.color.signal_alert_primary), + onClick = { + val progressDialog = ProgressCardDialogFragment.create(getString(R.string.ConversationFragment_deleting_messages)) + progressDialog.show(parentFragmentManager, null) + lifecycleScope.launch { + viewModel.deleteChat() + progressDialog.dismissAllowingStateLoss() + onToolbarNavigationClicked() + } + } + ) + } + } + + if (state.canModifyBlockedState && !state.isTerminatedGroup) { state.withRecipientSettingsState { dividerPref() } @@ -1005,6 +1068,50 @@ class ConversationSettingsFragment : ) } } + + state.withGroupSettingsState { groupState -> + if (groupState.isTerminated) { + dividerPref() + + val reportSpamTint = R.color.signal_alert_primary + clickPref( + title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)), + icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint), + onClick = { + BlockUnblockDialog.showReportSpamFor( + requireContext(), + viewLifecycleOwner.lifecycle, + state.recipient, + { + viewModel + .onReportSpam() + .subscribeBy { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() + onToolbarNavigationClicked() + } + .addTo(lifecycleDisposable) + }, + null + ) + } + ) + } + } + + state.withGroupSettingsState { groupState -> + if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) { + dividerPref() + + clickPref( + title = DSLSettingsText.from(R.string.ConversationSettingsFragment__end_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint), + icon = DSLSettingsIcon.from(endGroupIcon), + isEnabled = !state.isDeprecatedOrUnregistered, + onClick = { + EndGroupDialog.show(requireActivity(), groupState.groupId.requireV2(), groupState.groupTitle) + } + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index 0f7b20dc43..c92b688d30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -222,9 +222,18 @@ class ConversationSettingsRepository( return liveGroup.getMembershipCountDescription(context.resources) } - fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) { - SignalExecutors.BOUNDED.execute { - consumer(Recipient.externalPossiblyMigratedGroup(groupId).id) - } + @WorkerThread + fun isArchived(recipientId: RecipientId): Boolean { + return SignalDatabase.threads.isArchived(recipientId) + } + + @WorkerThread + fun setArchived(threadId: Long, archived: Boolean) { + SignalDatabase.threads.setArchived(setOf(threadId), archived) + } + + @WorkerThread + fun deleteChat(threadId: Long) { + SignalDatabase.threads.deleteConversation(threadId) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt index ba5936c6df..027a0a1e47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt @@ -20,6 +20,7 @@ data class ConversationSettingsState( val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(), val disappearingMessagesLifespan: Int = 0, val canModifyBlockedState: Boolean = false, + val isArchived: Boolean = false, val sharedMedia: List = emptyList(), val sharedMediaIds: List = listOf(), val displayInternalRecipientDetails: Boolean = false, @@ -29,6 +30,7 @@ data class ConversationSettingsState( ) { val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded + val isTerminatedGroup: Boolean = (specificSettingsState as? SpecificSettingsState.GroupSettingsState)?.isTerminated == true fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) { if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) { @@ -72,6 +74,8 @@ sealed class SpecificSettingsState { val isSelfAdmin: Boolean = false, val canAddToGroup: Boolean = false, val canEditGroupAttributes: Boolean = false, + val isActive: Boolean = false, + val isTerminated: Boolean = false, val canLeave: Boolean = false, val canShowMoreGroupMembers: Boolean = false, val groupMembersExpanded: Boolean = false, @@ -88,6 +92,8 @@ sealed class SpecificSettingsState { val canSetOwnMemberLabel: Boolean = false ) : SpecificSettingsState() { + val canEndGroup: Boolean get() = isActive && groupId.isV2 && isSelfAdmin + override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded override fun requireGroupSettingsState(): GroupSettingsState = this diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index 07d00036bb..dae71a9646 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -144,6 +144,26 @@ sealed class ConversationSettingsViewModel( disposable.clear() } + fun toggleArchive() { + val state = store.state + if (state.threadId > 0) { + val newArchived = !state.isArchived + store.update { it.copy(isArchived = newArchived) } + viewModelScope.launch(SignalDispatchers.IO) { + repository.setArchived(state.threadId, newArchived) + } + } + } + + suspend fun deleteChat() { + withContext(SignalDispatchers.IO) { + val threadId = store.state.threadId + if (threadId > 0) { + repository.deleteChat(threadId) + } + } + } + private class RecipientSettingsViewModel( private val recipientId: RecipientId, private val callMessageIds: LongArray, @@ -299,21 +319,21 @@ sealed class ConversationSettingsViewModel( store.update { it.copy(storyViewState = storyViewState) } } - val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a } - store.update(recipientAndIsActive) { (recipient, isActive), state -> + store.update(liveGroup.groupRecipient) { recipient, state -> state.copy( recipient = recipient, buttonStripState = ButtonStripPreference.State( isMessageAvailable = callMessageIds.isNotEmpty(), - isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive, + isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup, isAudioAvailable = false, isAudioSecure = recipient.isPushV2Group, isMuted = recipient.isMuted, isMuteAvailable = true, isSearchAvailable = callMessageIds.isEmpty(), - isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.story.isFeatureDisabled + isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup && !SignalStore.story.isFeatureDisabled ), canModifyBlockedState = RecipientUtil.isBlockable(recipient), + isArchived = repository.isArchived(recipient.id), specificSettingsState = state.requireGroupSettingsState().copy( legacyGroupState = getLegacyGroupState() ) @@ -398,11 +418,20 @@ sealed class ConversationSettingsViewModel( store.update(liveGroup.isActive) { isActive, state -> state.copy( specificSettingsState = state.requireGroupSettingsState().copy( + isActive = isActive, canLeave = isActive && groupId.isPush ) ) } + store.update(liveGroup.isTerminated) { isTerminated, state -> + state.copy( + specificSettingsState = state.requireGroupSettingsState().copy( + isTerminated = isTerminated + ) + ) + } + store.update(liveGroup.title) { title, state -> state.copy( specificSettingsState = state.requireGroupSettingsState().copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt index 1064ac4b68..c844d40a57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.groups.GroupAccessControl import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.LiveGroup import org.thoughtcrime.securesms.util.SingleLiveEvent +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil import org.thoughtcrime.securesms.util.livedata.Store class PermissionsSettingsViewModel( @@ -22,8 +23,8 @@ class PermissionsSettingsViewModel( val events: LiveData = internalEvents init { - store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state -> - state.copy(selfCanEditSettings = isSelfAdmin) + store.update(LiveDataUtil.combineLatest(liveGroup.isSelfAdmin, liveGroup.isActive) { admin, active -> admin && active }) { canEdit, state -> + state.copy(selfCanEditSettings = canEdit) } store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt index 3ff63e0512..24b241cb70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences import android.view.View +import android.view.ViewGroup import androidx.core.view.ViewCompat +import org.signal.core.util.dp import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.view.AvatarView import org.thoughtcrime.securesms.badges.BadgeImageView @@ -25,6 +27,7 @@ object AvatarPreference { class Model( val recipient: Recipient, val storyViewState: StoryViewState, + val reduceTopMargin: Boolean = false, val onAvatarClick: (AvatarView) -> Unit, val onBadgeClick: (Badge) -> Unit ) : PreferenceModel() { @@ -35,7 +38,8 @@ object AvatarPreference { override fun areContentsTheSame(newItem: Model): Boolean { return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) && - storyViewState == newItem.storyViewState + storyViewState == newItem.storyViewState && + reduceTopMargin == newItem.reduceTopMargin } } @@ -49,6 +53,10 @@ object AvatarPreference { } override fun bind(model: Model) { + (itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.let { + it.topMargin = if (model.reduceTopMargin) 0.dp else 40.dp + } + if (model.recipient.isSelf) { badge.setBadge(null) badge.setOnClickListener(null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt new file mode 100644 index 0000000000..18442b24b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/TerminatedBannerPreference.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.preferences + +import android.view.View +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +object TerminatedBannerPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_terminated_banner)) + } + + class Model : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean = true + override fun areContentsTheSame(newItem: Model): Boolean = true + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + override fun bind(model: Model) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 6ef5d9a6f7..5fd5f6bc74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -426,7 +426,7 @@ class ContactSearchPagedDataSource( } private fun canSendToGroup(groupRecord: GroupRecord?): Boolean { - if (groupRecord == null) return false + if (groupRecord == null || groupRecord.isTerminated) return false return if (groupRecord.isAnnouncementGroup) { groupRecord.isAdmin(Recipient.self()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 0ee6271c47..a4ac3dca8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -51,7 +51,7 @@ internal object ConversationOptionsMenu { canShowAsBubble, isActiveGroup, isActiveV2Group, - isInActiveGroup, + isInactiveGroup, hasActiveGroupCall, distributionType, threadId, @@ -104,12 +104,12 @@ internal object ConversationOptionsMenu { if (isPushAvailable) { if (recipient.expiresInSeconds > 0) { - if (!isInActiveGroup) { + if (!isInactiveGroup) { menuInflater.inflate(R.menu.conversation_expiring_on, menu) } callback.showExpiring(recipient) } else { - if (!isInActiveGroup) { + if (!isInactiveGroup) { menuInflater.inflate(R.menu.conversation_expiring_off, menu) } callback.clearExpiring() @@ -150,6 +150,11 @@ internal object ConversationOptionsMenu { hideMenuItem(menu, R.id.menu_mute_notifications) } + if (recipient.isGroup && isInactiveGroup) { + hideMenuItem(menu, R.id.menu_mute_notifications) + hideMenuItem(menu, R.id.menu_unmute_notifications) + } + if (recipient.isBlocked) { if (isPushAvailable) { hideMenuItem(menu, R.id.menu_call_secure) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index ae36933ad7..26f7c86d18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -171,11 +171,11 @@ public final class MenuState { hasPollTerminate = true; } - if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) { + if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) { canPinMessage = true; } - if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) { + if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) { canUnpinMessage = true; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index fa2cc6ea8d..3990f9200b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -573,10 +573,10 @@ class ConversationAdapterV2( } if (groupInfo.fullMemberCount > 0 || groupInfo.pendingMemberCount > 0) { - if (groupInfo.fullMemberCount == 1 && recipient.isActiveGroup) { + if (groupInfo.fullMemberCount == 1 && groupInfo.isMember) { conversationBanner.hideUnverifiedNameSubtitle() } - setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, recipient) + setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, groupInfo.isMember, recipient) } else { conversationBanner.hideSubtitle() } @@ -640,10 +640,10 @@ class ConversationAdapterV2( conversationBanner.updateOutlineBoxSize() } - private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List, recipient: Recipient) { + private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List, isMember: Boolean, recipient: Recipient) { val names = members.map { member -> member.getDisplayName(context) } val otherMembers = if (size > 3) context.resources.getQuantityString(R.plurals.MessageRequestProfileView_other_members, size - 3, size - 3) else null - val membersSubtitle = if (recipient.isActiveGroup) { + val membersSubtitle = if (isMember) { when (names.size) { 0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero) 1 -> context.getString(R.string.MessageRequestProfileView_group_members_one_and_you, names[0]) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt index 2b21d023c0..bf2c81a2f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt @@ -10,6 +10,7 @@ import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SimpleTask import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.recipients.Recipient @@ -94,6 +95,18 @@ object ConversationDialogs { .show() } + fun displayTerminatedGroupSendFailedDialog(context: Context, messageRecord: MessageRecord) { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.conversation_activity__send_failed_group_ended) + .setNegativeButton(R.string.ConversationFragment_delete_for_me) { _, _ -> + SignalExecutors.BOUNDED.execute { + SignalDatabase.messages.deleteMessage(messageRecord.id) + } + } + .setPositiveButton(android.R.string.ok, null) + .show() + } + fun displayDeletionFailedDialog(context: Context, messageRecord: MessageRecord, canRetry: Boolean) { if (canRetry) { MaterialAlertDialogBuilder(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 442263a75d..a25a4f0571 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -99,6 +99,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -1171,6 +1172,26 @@ class ConversationFragment : viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel)) + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + var wasTerminated: Boolean? = null + viewModel + .groupRecordFlow + .collect { record -> + val isTerminated = record.isTerminated + if (wasTerminated == false && isTerminated) { + val terminatedByRecipientId = record.terminatedByRecipientId + if (terminatedByRecipientId == null || terminatedByRecipientId != Recipient.self().id) { + val context = context ?: return@collect + val adminName = terminatedByRecipientId?.let { Recipient.resolved(it).getDisplayName(context) } + withContext(Dispatchers.Main) { + TerminatedGroupBottomSheetDialog.show(childFragmentManager, adminName) + } + } + } + wasTerminated = isTerminated + } + } + disposables += viewModel.recipient .observeOn(AndroidSchedulers.mainThread()) .distinctUntilChanged { r1, r2 -> r1 === r2 || r1.hasSameContent(r2) } @@ -1492,6 +1513,7 @@ class ConversationFragment : inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized) args.isIncognito -> disabledInputView.showAsIncognito() !inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState) + inputReadyState.isTerminatedGroup -> disabledInputView.showAsTerminatedGroup() inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember() inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember() inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly() @@ -3424,7 +3446,9 @@ class ConversationFragment : override fun onMessageWithErrorClicked(messageRecord: MessageRecord) { val recipientId = viewModel.recipientSnapshot?.id ?: return - if (messageRecord.isFailedAdminDelete) { + if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) { + ConversationDialogs.displayTerminatedGroupSendFailedDialog(requireContext(), messageRecord) + } else if (messageRecord.isFailedAdminDelete) { val canRetry = MessageConstraintsUtil.isValidAdminDeleteSend(message = messageRecord, currentTime = System.currentTimeMillis(), isAdmin = conversationGroupViewModel.isAdmin(), isResend = true) if (messageRecord.isIdentityMismatchFailure && canRetry) { SafetyNumberBottomSheet diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index f4068858ed..8dd32070ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -183,7 +183,7 @@ class ConversationViewModel( val messageRequestState: MessageRequestState get() = hasMessageRequestStateSubject.value ?: MessageRequestState() - private val groupRecordFlow: Flow + val groupRecordFlow: Flow private val refreshIdentityRecords: Subject = PublishSubject.create() private val identityRecordsStore: RxStore = RxStore(IdentityRecordsState()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt index 459c300c4d..fbc9a2ab3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt @@ -44,6 +44,7 @@ class DisabledInputView @JvmOverloads constructor( private var expiredOrUnauthorized: View? = null private var messageRequestView: MessageRequestsBottomView? = null private var noLongerAMember: View? = null + private var terminatedGroup: View? = null private var requestingGroup: View? = null private var announcementGroupOnly: TextView? = null private var inviteToSignal: View? = null @@ -126,6 +127,13 @@ class DisabledInputView @JvmOverloads constructor( ) } + fun showAsTerminatedGroup() { + terminatedGroup = show( + existingView = terminatedGroup, + create = { inflater.inflate(R.layout.conversation_group_terminated, this, false) } + ) + } + fun showAsRequestingMember() { requestingGroup = show( existingView = requestingGroup, @@ -216,6 +224,7 @@ class DisabledInputView @JvmOverloads constructor( messageRequestView?.hideBusy() messageRequestView = null noLongerAMember = null + terminatedGroup = null requestingGroup = null announcementGroupOnly = null incognitoView = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt index e723a35b7b..3c2cb6c6ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/InputReadyState.kt @@ -30,7 +30,8 @@ class InputReadyState( } val isAnnouncementGroup: Boolean? = groupRecord?.isAnnouncementGroup - val isActiveGroup: Boolean? = if (selfMemberLevel == null) null else selfMemberLevel != GroupTable.MemberLevel.NOT_A_MEMBER + val isActiveGroup: Boolean? = groupRecord?.isActive + val isTerminatedGroup: Boolean = groupRecord?.isTerminated == true val isAdmin: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.ADMINISTRATOR) val isRequestingMember: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.REQUESTING_MEMBER) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt index 58ffc112ce..851da7703e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt @@ -37,6 +37,11 @@ object PinSendUtil { if (groupId != null) { val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + + if (groupRecord != null && !groupRecord.isActive) { + throw UndeliverableMessageException("Cannot pin messages in an inactive group!") + } + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { throw UndeliverableMessageException("Non-admins cannot pin messages!") } @@ -83,6 +88,11 @@ object PinSendUtil { val groupId = if (threadRecipient.isPushV2Group) threadRecipient.requireGroupId().requireV2() else null if (groupId != null) { val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + + if (groupRecord != null && !groupRecord.isActive) { + throw UndeliverableMessageException("Cannot unpin messages in an inactive group!") + } + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { throw UndeliverableMessageException("Non-admins cannot pin messages!") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt new file mode 100644 index 0000000000..b57d1d7183 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.R + +/** + * Shown when a group is terminated while the user is actively viewing the conversation. + */ +class TerminatedGroupBottomSheetDialog : ComposeBottomSheetDialogFragment() { + + companion object { + private const val ARG_ADMIN_NAME = "admin_name" + + fun show(fragmentManager: FragmentManager, adminName: String?) { + TerminatedGroupBottomSheetDialog() + .apply { arguments = bundleOf(ARG_ADMIN_NAME to adminName) } + .show(fragmentManager, "terminated_group_sheet") + } + } + + @Composable + override fun SheetContent() { + TerminatedGroupSheetContent( + adminName = requireArguments().getString(ARG_ADMIN_NAME), + onOkClick = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun TerminatedGroupSheetContent(adminName: String?, onOkClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + BottomSheets.Handle() + + Text( + text = if (adminName != null) { + stringResource(R.string.TerminatedGroupBottomSheet__s_ended_the_group, adminName) + } else { + stringResource(R.string.TerminatedGroupBottomSheet__the_group_has_been_ended) + }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 24.dp, bottom = 10.dp) + ) + + Text( + text = stringResource(R.string.TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + Buttons.LargeTonal( + onClick = onOkClick, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 24.dp) + ) { + Text(text = stringResource(R.string.TerminatedGroupBottomSheet__okay)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index daef59f7c3..32ebd76aa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -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 { 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 || diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 38fe4b2954..11c12047fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 6ddef88d7b..e4b78acc42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -3745,7 +3745,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val threadDatabase = threads val recipientsWithinInteractionThreshold: MutableSet = 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 ?)" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 608c196da9..d886c96c7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -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 ) ) """ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 360992258e..c21e8b5724 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt new file mode 100644 index 0000000000..9977e617e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V309_GroupTerminatedColumnMigration.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt index 5c93936c6f..7610d84b16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -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 ?: "" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt index 9167535e74..90869a02bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt @@ -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) { + 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) { updates.add( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index aa85732769..737c8fc56e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -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 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 updates) { final int duration = Math.toIntExact(update.expiresInMs / 1000); String time = ExpirationUtil.getExpirationDisplayValue(context, duration); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 24a7154c57..f0e1d59a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -75,6 +75,23 @@ public final class GroupManager { } } + @WorkerThread + public static void terminateGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) + throws GroupChangeBusyException, GroupChangeFailedException, IOException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.terminateGroup(); + SignalDatabase.groups().setTerminatedBy(groupId, Recipient.self().getId()); + Log.i(TAG, "Terminated group " + groupId); + } catch (GroupInsufficientRightsException e) { + Log.w(TAG, "Insufficient rights to terminate " + groupId, e); + throw new GroupChangeFailedException(e); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "Not a member of " + groupId, e); + throw new GroupChangeFailedException(e); + } + } + @WorkerThread public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId, boolean sendToMembers) throws GroupChangeBusyException, GroupChangeFailedException, IOException @@ -216,7 +233,7 @@ public final class GroupManager { { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.acceptInvite(); - SignalDatabase.groups().setActive(groupId, true); + SignalDatabase.groups().setMember(groupId, true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 63540d2dbb..60c19d738f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -435,6 +435,13 @@ final class GroupManagerV2 { return commitChangeWithConflictResolution(selfAci, change); } + @WorkerThread + @NonNull GroupManager.GroupActionResult terminateGroup() + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(selfAci, groupOperations.createTerminateGroup()); + } + @WorkerThread void leaveGroup(boolean sendToMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 91f2e20ac8..f2aa1459d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -137,6 +137,10 @@ public final class LiveGroup { return recipient; } + public LiveData getGroupRecord() { + return groupRecord; + } + public LiveData isSelfAdmin() { return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self())); } @@ -149,6 +153,14 @@ public final class LiveGroup { return Transformations.map(groupRecord, GroupRecord::isActive); } + public LiveData isTerminated() { + return Transformations.map(groupRecord, GroupRecord::isTerminated); + } + + public LiveData isMember() { + return Transformations.map(groupRecord, GroupRecord::isMember); + } + public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId))); } @@ -201,11 +213,13 @@ public final class LiveGroup { } public LiveData selfCanEditGroupAttributes() { - return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl); + return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), isActive(), + (level, access, active) -> active && applyAccessControl(level, access)); } public LiveData selfCanAddMembers() { - return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl); + return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), isActive(), + (level, access, active) -> active && applyAccessControl(level, access)); } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt index 0a1be6d120..53a633dbae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -98,6 +98,8 @@ class MemberLabelRepository private constructor( suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) { val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false + if (groupRecord.isTerminated) return@withContext false + val memberLevel = groupRecord.memberLevel(recipient) if (groupRecord.memberLabelAccessControl == GroupAccessControl.ONLY_ADMINS) { memberLevel == GroupTable.MemberLevel.ADMINISTRATOR diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt new file mode 100644 index 0000000000..6311ac3d41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/EndGroupDialog.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.SignalProgressDialog +import org.thoughtcrime.securesms.groups.GroupChangeBusyException +import org.thoughtcrime.securesms.groups.GroupChangeException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import java.io.IOException + +/** + * Handles the end group flow for admins. Shows a two-step confirmation + * dialog before terminating the group. + */ +object EndGroupDialog { + + private val TAG = Log.tag(EndGroupDialog::class.java) + + @JvmStatic + fun show(activity: FragmentActivity, groupId: GroupId.V2, groupName: String) { + MaterialAlertDialogBuilder(activity) + .setTitle(activity.getString(R.string.EndGroupDialog__end_s, groupName)) + .setMessage(R.string.EndGroupDialog__members_will_no_longer_be_able_to_send) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ -> + showFinalConfirmation(activity, groupId) + } + .show() + } + + private fun showFinalConfirmation(activity: FragmentActivity, groupId: GroupId.V2) { + MaterialAlertDialogBuilder(activity) + .setMessage(R.string.EndGroupDialog__this_will_end_the_group_permanently) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ -> + performEndGroup(activity, groupId) + } + .show() + } + + private fun performEndGroup(activity: FragmentActivity, groupId: GroupId.V2) { + val progressDialog = SignalProgressDialog.show( + context = activity, + message = activity.getString(R.string.EndGroupDialog__ending_group), + indeterminate = true + ) + + activity.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + try { + GroupManager.terminateGroup(activity, groupId) + GroupChangeResult.SUCCESS + } catch (e: GroupChangeException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: GroupChangeBusyException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, "Failed to end group", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } + } + progressDialog.dismiss() + if (!result.isSuccess) { + showRetryDialog(activity, groupId) + } + } + } + + private fun showRetryDialog(activity: FragmentActivity, groupId: GroupId.V2) { + MaterialAlertDialogBuilder(activity) + .setMessage(R.string.EndGroupDialog__ending_the_group_failed) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.EndGroupDialog__try_again) { _, _ -> + performEndGroup(activity, groupId) + } + .show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 5dbdcdde9a..8b5c2916e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -116,7 +116,12 @@ class GroupsV2StateProcessor private constructor( return GroupUpdateResult.CONSISTENT_OR_AHEAD } - return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isActive)) { + if (currentLocalState.terminated) { + Log.i(TAG, "$logPrefix Group is terminated, not updating") + return GroupUpdateResult.CONSISTENT_OR_AHEAD + } + + return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isMember)) { InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState) is InternalUpdateResult.NotAMember -> throw result.exception @@ -232,6 +237,11 @@ class GroupsV2StateProcessor private constructor( return false } + if (currentLocalState.terminated) { + Log.w(TAG, "$logPrefix Ignoring P2P group change because group is terminated") + return false + } + if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) { Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.") return false @@ -320,8 +330,8 @@ class GroupsV2StateProcessor private constructor( val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision) val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState - if (groupRecord.map { it.isActive }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) { - Log.w(TAG, "$logPrefix Local state is not active, but server is returning state for us, apply regardless of revision") + if (groupRecord.map { it.isMember }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) { + Log.w(TAG, "$logPrefix Local state is not a member, but server is returning state for us, apply regardless of revision") } else if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) { Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}") @@ -436,7 +446,7 @@ class GroupsV2StateProcessor private constructor( } private fun notInGroupAndNotBeingAdded(groupRecord: Optional, signedGroupChange: DecryptedGroupChange): Boolean { - val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive + val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isMember val addedAsMember = signedGroupChange .newMembers @@ -517,6 +527,20 @@ class GroupsV2StateProcessor private constructor( saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements) + if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) { + val terminatingChange = groupStateDiff.serverHistory + .mapNotNull { it.change } + .firstOrNull { it.terminateGroup } + + if (terminatingChange != null) { + val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes) + if (editorServiceId != null) { + val terminatorRecipientId = RecipientId.from(editorServiceId) + SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId) + } + } + } + if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) { Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder") profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null) @@ -707,7 +731,7 @@ class GroupsV2StateProcessor private constructor( Log.w(TAG, "Failed to insert leave message for $groupId", e) } - SignalDatabase.groups.setActive(groupId, false) + SignalDatabase.groups.setMember(groupId, false) SignalDatabase.groups.remove(groupId, Recipient.self().id) } @@ -772,20 +796,24 @@ class GroupsV2StateProcessor private constructor( } } else { try { - val isGroupAdd = updateDescription - .groupChangeUpdate!! - .updates + val updates = updateDescription.groupChangeUpdate!!.updates + + val isGroupAdd = updates .asSequence() .mapNotNull { it.groupMemberAddedUpdate } .any { serviceIds.matches(it.newMemberAci) } - val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isGroupAdd, serverGuid) + val isGroupTerminate = updates.any { it.groupTerminateChangeUpdate != null } + + val isNotifiable = isGroupAdd || isGroupTerminate + + val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isNotifiable, serverGuid) val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage) if (insertResult.isPresent) { SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false) - if (isGroupAdd) { + if (isNotifiable) { AppDependencies.messageNotifier.updateNotification(AppDependencies.application) } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java index 872ae332db..6d7e952681 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java @@ -74,6 +74,11 @@ final class ForceUpdateGroupV2WorkerJob extends BaseJob { return; } + if (group.isPresent() && group.get().isTerminated()) { + Log.i(TAG, "Group is terminated, skipping force update."); + return; + } + GroupManager.forceSanityUpdateFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), System.currentTimeMillis()); SignalDatabase.groups().setLastForceUpdateTimestamp(group.get().getId(), System.currentTimeMillis()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java index ab7531e0e9..f9a903d43e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -89,6 +89,11 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob { return; } + if (group.isPresent() && group.get().isTerminated()) { + Log.i(TAG, "Group is terminated, skipping fetch."); + return; + } + GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java index 1e06ab1b40..b26a1b2161 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java @@ -88,7 +88,7 @@ class CameraContactsRepository { List recipients = new ArrayList<>(RECENT_MAX); - try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX, false))) { + try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX))) { ThreadRecord threadRecord; while ((threadRecord = threadReader.getNext()) != null) { recipients.add(threadRecord.getRecipient().resolve()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt index 2433bdfc98..748a3dda7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt @@ -10,7 +10,9 @@ class GroupInfo( val pendingMemberCount: Int = 0, val description: String = "", val hasExistingContacts: Boolean = false, - val membersPreview: List = emptyList() + val membersPreview: List = emptyList(), + val isMember: Boolean = false, + val isTerminated: Boolean = false ) { companion object { @JvmField diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index b11815c2f9..7566ee4e12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -74,11 +74,11 @@ public final class MessageRequestRepository { List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup(); - groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview); + groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated()); } else { List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); - groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview); + groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index e0c46b8506..1f37033110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -255,6 +255,13 @@ open class MessageContentProcessor(private val context: Context) { return Gv2PreProcessResult.IGNORE } + if (groupRecord.isPresent && groupRecord.get().isTerminated) { + if (content.dataMessage != null && !content.dataMessage!!.hasSignedGroupChange) { + Log.w(TAG, "Ignoring message from ${senderRecipient.id} because the group is terminated.") + return Gv2PreProcessResult.IGNORE + } + } + if (groupRecord.isPresent && groupRecord.get().isAnnouncementGroup && !groupRecord.get().admins.contains(senderRecipient)) { if (content.dataMessage != null) { if (content.dataMessage!!.hasDisallowedAnnouncementOnlyContent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt index b0fae831b7..671490403e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt @@ -40,7 +40,7 @@ class IncomingMessage( mentions: List = emptyList(), val giftBadge: GiftBadge? = null, val messageExtras: MessageExtras? = null, - val isGroupAdd: Boolean = false, + val isNotifiable: Boolean = false, val poll: Poll? = null ) { @@ -99,7 +99,7 @@ class IncomingMessage( } @JvmStatic - fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isGroupAdd: Boolean, serverGuid: String?): IncomingMessage { + fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isNotifiable: Boolean, serverGuid: String?): IncomingMessage { val messageExtras = MessageExtras(gv2UpdateDescription = update) val groupContext = MessageGroupContext(update.gv2ChangeDescription!!) @@ -113,7 +113,7 @@ class IncomingMessage( groupContext = groupContext, type = MessageType.GROUP_UPDATE, messageExtras = messageExtras, - isGroupAdd = isGroupAdd + isNotifiable = isNotifiable ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java index 48f683375b..0805f2f6b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/CreateProfileFragment.java @@ -323,6 +323,7 @@ public class CreateProfileFragment extends LoggingFragment { } } else { Toast.makeText(requireContext(), R.string.CreateProfileActivity_problem_setting_profile, Toast.LENGTH_LONG).show(); + binding.finishButton.cancelSpinning(); } }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java index 83a431bc2d..1ce2edd56c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -113,6 +113,7 @@ class EditGroupProfileRepository implements EditProfileRepository { return UploadResult.SUCCESS; } catch (GroupChangeException | IOException e) { + Log.d(TAG, "Error updating group details", e); return UploadResult.ERROR_IO; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index f9a75a80c5..50357e5f45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -248,7 +248,7 @@ class Recipient( participantIdsValue.isEmpty() || participantIdsValue.size == 1 && participantIdsValue.contains(self().id) } - /** Whether the group is inactive. Groups become inactive when you leave them. */ + /** Whether the group is inactive. Groups become inactive when you leave them or when the group is terminated. */ val isInactiveGroup: Boolean get() = isGroup && !isActiveGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index abe455f2c0..344e0457cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -11,7 +11,6 @@ import androidx.annotation.WorkerThread; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; @@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.BlockUnblockDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; import org.thoughtcrime.securesms.conversation.colors.ColorizerV2; +import org.signal.storageservice.storage.protos.groups.AccessControl; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.GroupRecord; @@ -53,8 +53,6 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; import java.util.Objects; import java.util.Optional; -import kotlin.Pair; - import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; @@ -92,18 +90,19 @@ final class RecipientDialogViewModel extends ViewModel { if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) { LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId()); - LiveData> localStatus = LiveDataUtil.combineLatest(source.isSelfAdmin(), Transformations.map(source.getGroupLink(), s -> s == null || s.isEnabled()), Pair::new); - LiveData recipientMemberLevel = Transformations.switchMap(recipient, source::getMemberLevel); + adminActionStatus = LiveDataUtil.combineLatest(source.getGroupRecord(), recipient, (group, r) -> { + boolean active = group.isActive(); + boolean localAdmin = group.isAdmin(Recipient.self()); + GroupTable.MemberLevel memberLevel = group.memberLevel(r); + boolean inGroup = memberLevel.isInGroup(); + boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; + AccessControl.AccessRequired linkAccess = group.requireV2GroupProperties().getDecryptedGroup().accessControl != null ? group.requireV2GroupProperties().getDecryptedGroup().accessControl.addFromInviteLink + : AccessControl.AccessRequired.UNKNOWN; + boolean isLinkActive = linkAccess == AccessControl.AccessRequired.ANY || linkAccess == AccessControl.AccessRequired.ADMINISTRATOR; - adminActionStatus = LiveDataUtil.combineLatest(localStatus, recipientMemberLevel, (statuses, memberLevel) -> { - boolean localAdmin = statuses.getFirst(); - boolean isLinkActive = statuses.getSecond(); - boolean inGroup = memberLevel.isInGroup(); - boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; - - return new AdminActionStatus(inGroup && localAdmin, - inGroup && localAdmin && !recipientAdmin, - inGroup && localAdmin && recipientAdmin, + return new AdminActionStatus(active && inGroup && localAdmin, + active && inGroup && localAdmin && !recipientAdmin, + active && inGroup && localAdmin && recipientAdmin, isLinkActive); }); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java index dcc73a1118..6ec27cd20f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkViewModel.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; final class ShareableGroupLinkViewModel extends ViewModel { @@ -27,7 +28,7 @@ final class ShareableGroupLinkViewModel extends ViewModel { this.repository = repository; this.groupLink = liveGroup.getGroupLink(); - this.canEdit = liveGroup.isSelfAdmin(); + this.canEdit = LiveDataUtil.combineLatest(liveGroup.isSelfAdmin(), liveGroup.isActive(), (admin, active) -> admin && active); this.toasts = new SingleLiveEvent<>(); this.busy = new SingleLiveEvent<>(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 242edd72aa..aefa38db27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -286,8 +286,8 @@ public class CommunicationActions { SimpleTask.run(SignalExecutors.BOUNDED, () -> { GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null); - return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId()) - : null; + return group != null && (group.isMember() || group.isTerminated()) ? Recipient.resolved(group.getRecipientId()) + : null; }, recipient -> { if (recipient != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index abe24258a4..ad0807bd7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1303,5 +1303,17 @@ object RemoteConfig { defaultValue = 0, hotSwappable = true ) + + /** + * Whether or not to allow admins to terminate groups. + */ + @JvmStatic + @get:JvmName("groupTerminateSend") + val groupTerminateSend: Boolean by remoteBoolean( + key = "android.groupTerminateSend", + defaultValue = false, + hotSwappable = true + ) + // endregion } diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 817a1d4c4f..8242bb8123 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -308,6 +308,7 @@ message Group { bytes inviteLinkPassword = 10; bool announcements_only = 12; repeated MemberBanned members_banned = 13; + bool terminated = 14; } message GroupAttributeBlob { @@ -1078,6 +1079,7 @@ message GroupChangeChatUpdate { GroupSequenceOfRequestsAndCancelsUpdate groupSequenceOfRequestsAndCancelsUpdate = 33; GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34; GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35; + GroupTerminateChangeUpdate groupTerminateChangeUpdate = 36; } } @@ -1134,6 +1136,10 @@ message GroupMemberLabelAccessLevelChangeUpdate { GroupV2AccessLevel accessLevel = 2; } +message GroupTerminateChangeUpdate { + optional bytes updaterAci = 1; +} + message GroupAnnouncementOnlyChangeUpdate { optional bytes updaterAci = 1; bool isAnnouncementOnly = 2; diff --git a/app/src/main/res/drawable/terminated_banner_background.xml b/app/src/main/res/drawable/terminated_banner_background.xml new file mode 100644 index 0000000000..f51ceb8706 --- /dev/null +++ b/app/src/main/res/drawable/terminated_banner_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout/conversation_group_terminated.xml b/app/src/main/res/layout/conversation_group_terminated.xml new file mode 100644 index 0000000000..bd4b450b4a --- /dev/null +++ b/app/src/main/res/layout/conversation_group_terminated.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/layout/conversation_settings_terminated_banner.xml b/app/src/main/res/layout/conversation_settings_terminated_banner.xml new file mode 100644 index 0000000000..515c964cb6 --- /dev/null +++ b/app/src/main/res/layout/conversation_settings_terminated_banner.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45bea3c76e..31cc99854e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -519,6 +519,7 @@ Unable to record audio! You can\'t send messages to this group because you\'re no longer a member. Incognito mode (Labs) + You can\'t send messages because the group was ended. Only %1$s can send messages. admins Message an admin @@ -1882,6 +1883,12 @@ You have left the group. You updated the group. The group was updated. + + The group has been ended + + %1$s ended the group + + You ended the group Outgoing voice call @@ -3616,6 +3623,8 @@ Lock recording of audio attachment Message could not be sent. Check your connection and try again. + + Send failed because the group was ended. You can no longer send and receive messages in this group. Message failed to delete. Check your connection and try again. @@ -6034,6 +6043,19 @@ Get badges for your profile by supporting Signal. Tap on a badge to learn more. This media is not sent yet. + + End group + + This group was ended. + + Archive chat + + Delete chat + + + %1$d former member + %1$d former members + Add members @@ -8505,6 +8527,30 @@ Not now + + End group + + End \"%1$s\"? + + Members will no longer be able to send messages or start calls in the group. They will be notified that you ended the group, and will still have access to message history. + + This will end the group permanently. Are you sure you want to proceed? + + Ending group\u2026 + + Ending the group failed. Check your connection and try again. + + Try again + + + %1$s Ended the Group + + The group has been ended + + You can no longer send and receive messages or calls in this group. + + Okay + Deleting is now synced across all of your devices diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt new file mode 100644 index 0000000000..e10733b67b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.thoughtcrime.securesms.groups.GroupId + +class GroupSettingsStateTest { + + private val v2GroupId = GroupId.v2(org.signal.libsignal.zkgroup.groups.GroupMasterKey(ByteArray(32))) + private val v1GroupId = GroupId.v1(ByteArray(16)) + + private fun createState( + groupId: GroupId = v2GroupId, + isActive: Boolean = true, + isSelfAdmin: Boolean = true, + canLeave: Boolean = true + ): SpecificSettingsState.GroupSettingsState { + return SpecificSettingsState.GroupSettingsState( + groupId = groupId, + isActive = isActive, + isSelfAdmin = isSelfAdmin, + canLeave = canLeave + ) + } + + @Test + fun `canEndGroup is true when active v2 group and self is admin`() { + assertTrue(createState().canEndGroup) + } + + @Test + fun `canEndGroup is false when group is not active`() { + assertFalse(createState(isActive = false).canEndGroup) + } + + @Test + fun `canEndGroup is false when self is not admin`() { + assertFalse(createState(isSelfAdmin = false).canEndGroup) + } + + @Test + fun `canEndGroup is false for v1 group`() { + assertFalse(createState(groupId = v1GroupId).canEndGroup) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt index 93e60f3ed7..899a1395eb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt @@ -31,6 +31,7 @@ class PermissionsSettingsViewModelTest { ): PermissionsSettingsViewModel { val liveGroup = mockk { every { isSelfAdmin } returns MutableLiveData(false) + every { isActive } returns MutableLiveData(true) every { membershipAdditionAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) every { attributesAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) every { isAnnouncementGroup } returns MutableLiveData(false) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index fc63193870..d9f1775066 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -107,6 +107,10 @@ class GroupChangeData(private val revision: Int, private val groupOperations: Gr fun changeMemberLabelAccess(access: AccessControl.AccessRequired) { actionsBuilder.modifyMemberLabelAccess = GroupChange.Actions.ModifyMemberLabelAccessControlAction(memberLabelAccess = access) } + + fun terminateGroup() { + actionsBuilder.terminate_group = GroupChange.Actions.TerminateGroupAction() + } } class GroupStateTestData(private val masterKey: GroupMasterKey, private val groupOperations: GroupsV2Operations.GroupOperations? = null) { @@ -134,9 +138,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou requestingMembers: List = emptyList(), inviteLinkPassword: ByteArray = ByteArray(0), disappearingMessageTimer: DecryptedTimer = DecryptedTimer(), - isPlaceholderGroup: Boolean = false + isPlaceholderGroup: Boolean = false, + terminated: Boolean = false ) { - localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup) + localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, isPlaceholderGroup, terminated) groupRecord = groupRecord(masterKey, localState!!, active = active) } @@ -151,9 +156,10 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou pendingMembers: List = extendGroup?.pendingMembers ?: emptyList(), requestingMembers: List = extendGroup?.requestingMembers ?: emptyList(), inviteLinkPassword: ByteArray = extendGroup?.inviteLinkPassword?.toByteArray() ?: ByteArray(0), - disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer() + disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer(), + terminated: Boolean = extendGroup?.terminated ?: false ) { - serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer) + serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer, terminated = terminated) } fun changeSet(init: ChangeSet.() -> Unit) { @@ -200,6 +206,7 @@ fun groupRecord( avatarKey, avatarContentType, active, + terminatedBy = if (decryptedGroup.terminated) -1L else 0L, avatarDigest, mms, masterKey.serialize(), @@ -223,7 +230,8 @@ fun decryptedGroup( requestingMembers: List = emptyList(), inviteLinkPassword: ByteArray = ByteArray(0), disappearingMessageTimer: DecryptedTimer = DecryptedTimer(), - isPlaceholderGroup: Boolean = false + isPlaceholderGroup: Boolean = false, + terminated: Boolean = false ): DecryptedGroup { return DecryptedGroup( accessControl = accessControl, @@ -237,6 +245,7 @@ fun decryptedGroup( members = members, pendingMembers = pendingMembers, requestingMembers = requestingMembers, - isPlaceholderGroup = isPlaceholderGroup + isPlaceholderGroup = isPlaceholderGroup, + terminated = terminated ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt index 504e2de9c0..05da91a3e5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt @@ -310,4 +310,30 @@ class GroupManagerV2Test_edit { assertThat(other2.labelString, "Other2's label text is preserved").isEqualTo("Bar") } } + + @Test + fun `when admin terminates group, the group state is updated with terminated flag`() { + given { + localState( + revision = 5, + members = listOf( + member(selfAci, role = Member.Role.ADMINISTRATOR), + member(otherAci) + ) + ) + groupChange(6) { + source(selfAci) + terminateGroup() + } + } + + editGroup { + terminateGroup() + } + + then { patchedGroup -> + assertThat(patchedGroup.revision, "Revision updated by one").isEqualTo(6) + assertThat(patchedGroup.terminated, "Group is terminated").isEqualTo(true) + } + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index c2df10353f..0494cdd223 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.SystemOutLogger import org.whispersystems.signalservice.api.NetworkResult @@ -1050,6 +1051,152 @@ class GroupsV2StateProcessorTest { assertThat(result.updateStatus, "inactive local is still updated given same revision from server").isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) } + @Test + fun `when P2P change terminates group with known editor, then setTerminatedBy is called`() { + val adminAci: ACI = ACI.from(UUID.randomUUID()) + val adminRecipientId = RecipientId.from(200) + + given { + localState( + revision = 5, + members = selfAndOthers + ) + expectTableUpdate = true + } + + every { recipientTable.getAndPossiblyMerge(adminAci, null) } returns adminRecipientId + justRun { groupTable.setTerminatedBy(groupId, adminRecipientId) } + + val signedChange = DecryptedGroupChange( + revision = 6, + editorServiceIdBytes = adminAci.toByteString(), + terminateGroup = true + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify { groupTable.setTerminatedBy(groupId, adminRecipientId) } + } + + @Test + fun `when P2P change terminates group without editor, then setTerminatedBy is not called`() { + given { + localState( + revision = 5, + members = selfAndOthers + ) + expectTableUpdate = true + } + + val signedChange = DecryptedGroupChange( + revision = 6, + terminateGroup = true + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) } + } + + @Test + fun `when force sanity update finds terminated group, then setTerminatedBy is not called because reconstructed change has no editor`() { + given { + localState( + revision = 10, + title = "Title", + members = selfAndOthers + ) + serverState( + revision = 11, + title = "Title", + members = selfAndOthers, + terminated = true + ) + expectTableUpdate = true + } + + val result = processor.forceSanityUpdateFromServer(0) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + assertThat(result.latestServer) + .isNotNull() + .transform { + assertThat(it.terminated, "group should be terminated").isEqualTo(true) + } + + verify(exactly = 0) { groupTable.setTerminatedBy(any(), any()) } + } + + @Test + fun `when group is already terminated, then force sanity update returns consistent`() { + given { + localState( + revision = 10, + members = selfAndOthers, + terminated = true + ) + } + + val result = processor.forceSanityUpdateFromServer(0) + + assertThat(result.updateStatus, "already terminated group should not update") + .isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) + } + + @Test + fun `when P2P change is received for terminated group, then P2P change is not applied`() { + given { + localState( + revision = 5, + members = selfAndOthers, + terminated = true + ) + changeSet { + } + apiCallParameters(requestedRevision = 5, includeFirst = false) + joinedAtRevision = 0 + } + + val signedChange = DecryptedGroupChange( + revision = 6, + newTitle = DecryptedString("New Title") + ) + + val result = processor.updateLocalGroupToRevision( + targetRevision = 6, + timestamp = 0, + signedGroupChange = signedChange, + serverGuid = UUID.randomUUID().toString() + ) + + assertThat(result.updateStatus, "terminated group should not accept P2P changes") + .isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) + } + /** * If we get a 500 back from the service we handle it gracefully. */ diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java index 2ca413a6fe..cc2ce1ea7b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java @@ -50,4 +50,6 @@ public interface ChangeSetModifier { void removeModifyMemberLabels(int i); void clearModifyMemberLabelAccess(); + + void clearTerminateGroup(); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt index e8971cb9f0..b58a5f969f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.kt @@ -118,6 +118,10 @@ internal class DecryptedGroupChangeActionsBuilderChangeSetModifier(private val r result.newMemberLabelAccess = AccessControl.AccessRequired.UNKNOWN } + override fun clearTerminateGroup() { + result.terminateGroup = false + } + private fun List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt index 3061d2bbe1..59ee20c86b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupExtensions.kt @@ -89,6 +89,7 @@ fun DecryptedGroupChange.getChangedFields(): Set { if (newRequestingMembers.isNotEmpty()) add(GroupChangeField.REQUESTING_MEMBERS) if (newTimer != null) add(GroupChangeField.TIMER) if (newTitle != null) add(GroupChangeField.TITLE) + if (terminateGroup) add(GroupChangeField.TERMINATE_GROUP) } } @@ -129,6 +130,7 @@ enum class GroupChangeField(val changeSilently: Boolean = false) { REQUESTING_MEMBER_APPROVALS, REQUESTING_MEMBER_REMOVALS, REQUESTING_MEMBERS, + TERMINATE_GROUP, TIMER, TITLE; diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 5607ce9668..5d5f75653f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -341,6 +341,8 @@ public final class DecryptedGroupUtil { DecryptedGroupExtensions.setModifyMemberLabelActions(builder, change.modifyMemberLabels); + applyTerminateGroup(builder, change); + return builder.build(); } @@ -535,6 +537,12 @@ public final class DecryptedGroupUtil { } } + private static void applyTerminateGroup(DecryptedGroup.Builder builder, DecryptedGroupChange change) { + if (change.terminateGroup) { + builder.terminated(true); + } + } + private static void applyAddRequestingMembers(DecryptedGroup.Builder builder, List newRequestingMembers) { List requestingMembers = new ArrayList<>(builder.requestingMembers); requestingMembers.addAll(newRequestingMembers); diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt index f4a2f827e7..ae9e386e4f 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.kt @@ -109,6 +109,10 @@ internal class GroupChangeActionsBuilderChangeSetModifier(private val result: Gr result.modifyMemberLabelAccess = null } + override fun clearTerminateGroup() { + result.terminate_group = null + } + private fun List.removeIndex(i: Int): List { val modifiedList = this.toMutableList() modifiedList.removeAt(i) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java index 84a63e91ef..4eba002ef8 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java @@ -186,6 +186,10 @@ public final class GroupChangeReconstruct { }) .collect(Collectors.toList())); + if (!fromState.terminated && toState.terminated) { + builder.terminateGroup(true); + } + return builder.build(); } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java index 359b35b49e..bb9c6e23b1 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java @@ -52,7 +52,8 @@ public final class GroupChangeUtil { change.delete_members_banned.isEmpty() && // field 23 change.promote_members_pending_pni_aci_profile_key.isEmpty() && // field 24 change.modifyMemberLabels.isEmpty() && // field 26 - change.modifyMemberLabelAccess == null; // field 27 + change.modifyMemberLabelAccess == null && // field 27 + change.terminate_group == null; // field 28 } /** @@ -157,6 +158,7 @@ public final class GroupChangeUtil { resolveField24PromotePendingPniAciMembers (conflictingChange, changeSetModifier, fullMembersByUuid); resolveField26ModifyMemberLabels (conflictingChange, changeSetModifier, fullMembersByUuid); resolveField27ModifyMemberLabelAccess (groupState, conflictingChange, changeSetModifier); + resolveField28TerminateGroup (groupState, conflictingChange, changeSetModifier); } private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap fullMembersByUuid, HashMap pendingMembersByServiceId) { @@ -403,4 +405,13 @@ public final class GroupChangeUtil { result.clearModifyMemberLabelAccess(); } } + + private static void resolveField28TerminateGroup(@Nonnull DecryptedGroup groupState, + @Nonnull DecryptedGroupChange conflictingChange, + @Nonnull ChangeSetModifier result) + { + if (groupState.terminated && conflictingChange.terminateGroup) { + result.clearTerminateGroup(); + } + } } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index d6112c893f..86ba302c22 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -75,7 +75,7 @@ public final class GroupsV2Operations { public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID; /** Highest change epoch this class knows now to decrypt */ - public static final int HIGHEST_KNOWN_EPOCH = 6; + public static final int HIGHEST_KNOWN_EPOCH = 7; private final ServerPublicParams serverPublicParams; private final ClientZkProfileOperations clientZkProfileOperations; @@ -350,6 +350,12 @@ public final class GroupsV2Operations { ); } + public GroupChange.Actions.Builder createTerminateGroup() { + return new GroupChange.Actions.Builder().terminate_group( + new GroupChange.Actions.TerminateGroupAction.Builder().build() + ); + } + public GroupChange.Actions.Builder createAnnouncementGroupChange(boolean isAnnouncementGroup) { return new GroupChange.Actions.Builder().modify_announcements_only( new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder().announcements_only(isAnnouncementGroup).build() @@ -493,6 +499,7 @@ public final class GroupsV2Operations { .disappearingMessagesTimer(new DecryptedTimer.Builder().duration(decryptDisappearingMessagesTimer(group.disappearingMessagesTimer)).build()) .inviteLinkPassword(group.inviteLinkPassword) .bannedMembers(decryptedBannedMembers) + .terminated(group.terminated) .build(); } @@ -781,6 +788,11 @@ public final class GroupsV2Operations { builder.newMemberLabelAccess(actions.modifyMemberLabelAccess.memberLabelAccess); } + // Field 28 + if (actions.terminate_group != null) { + builder.terminateGroup(true); + } + if (editorServiceId instanceof ServiceId.PNI) { if (actions.addMembers.size() == 1 && builder.newMembers.size() == 1) { GroupChange.Actions.AddMemberAction addMemberAction = actions.addMembers.get(0); diff --git a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto index 66993d33c8..bee930b9b2 100644 --- a/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto +++ b/lib/libsignal-service/src/main/protowire/DecryptedGroups.proto @@ -79,6 +79,7 @@ message DecryptedGroup { string description = 11; EnabledState isAnnouncementGroup = 12; repeated DecryptedBannedMember bannedMembers = 13; + bool terminated = 14; bool isPlaceholderGroup = 64; } @@ -112,6 +113,7 @@ message DecryptedGroupChange { repeated DecryptedMember promotePendingPniAciMembers = 24; repeated DecryptedModifyMemberLabel modifyMemberLabels = 26; AccessControl.AccessRequired newMemberLabelAccess = 27; + bool terminateGroup = 28; } message DecryptedString { diff --git a/lib/libsignal-service/src/main/protowire/Groups.proto b/lib/libsignal-service/src/main/protowire/Groups.proto index e15ace6875..598705e390 100644 --- a/lib/libsignal-service/src/main/protowire/Groups.proto +++ b/lib/libsignal-service/src/main/protowire/Groups.proto @@ -88,7 +88,8 @@ message Group { bytes inviteLinkPassword = 10; bool announcements_only = 12; repeated MemberBanned members_banned = 13; - // next: 14 + bool terminated = 14; + // next: 15 } message GroupAttributeBlob { @@ -238,6 +239,8 @@ message GroupChange { bool announcements_only = 1; } + message TerminateGroupAction {} + bytes sourceUserId = 1; // clients should not provide this value; the server will provide it in the response buffer to ensure the signature is binding to a particular group // if clients set it during a request the server will respond with 400. @@ -268,7 +271,8 @@ message GroupChange { repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5 repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6; ModifyMemberLabelAccessControlAction modifyMemberLabelAccess = 27; // change epoch = 6 - // next: 28 + TerminateGroupAction terminate_group = 28; // change epoch = 7 + // next: 29 } bytes actions = 1; diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java index 58a2352018..ef265dc586 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java @@ -51,7 +51,7 @@ public final class DecryptedGroupUtil_apply_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -1084,4 +1084,23 @@ public final class DecryptedGroupUtil_apply_Test { assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange)); } + + @Test + public void apply_terminate_group() throws NotAbleToApplyGroupV2ChangeException { + DecryptedGroup group = new DecryptedGroup.Builder() + .revision(10) + .build(); + + DecryptedGroupChange groupChange = new DecryptedGroupChange.Builder() + .revision(11) + .terminateGroup(true) + .build(); + + DecryptedGroup expectedResult = new DecryptedGroup.Builder() + .revision(11) + .terminated(true) + .build(); + + assertEquals(expectedResult, DecryptedGroupUtil.apply(group, groupChange)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java index 8774309b0d..973b174378 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java @@ -41,7 +41,7 @@ public final class DecryptedGroupUtil_empty_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeField and getChangedFields() need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -295,6 +295,16 @@ public final class DecryptedGroupUtil_empty_Test { assertFalse(DecryptedGroupExtensions.isSilent(change)); } + @Test + public void not_empty_with_terminate_group_field_28() { + DecryptedGroupChange change = new DecryptedGroupChange.Builder() + .terminateGroup(true) + .build(); + + assertFalse(DecryptedGroupExtensions.getChangedFields(change).isEmpty()); + assertFalse(DecryptedGroupExtensions.isSilent(change)); + } + @Test public void silent_with_profile_keys_and_banned_members() { DecryptedGroupChange change = new DecryptedGroupChange.Builder() diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java index 7c72889603..f28d8486ef 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java @@ -45,7 +45,7 @@ public final class GroupChangeReconstructTest { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeReconstruct and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @Test @@ -487,4 +487,22 @@ public final class GroupChangeReconstructTest { .build(), decryptedGroupChange); } + + @Test + public void terminate_group() { + DecryptedGroup from = new DecryptedGroup.Builder() + .build(); + + DecryptedGroup to = new DecryptedGroup.Builder() + .terminated(true) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals( + new DecryptedGroupChange.Builder() + .terminateGroup(true) + .build(), + decryptedGroupChange); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java index 51d1fae5e8..91ec41900c 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java @@ -22,7 +22,7 @@ public final class GroupChangeUtil_changeIsEmpty_Test { int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class); assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } @Test @@ -245,4 +245,13 @@ public final class GroupChangeUtil_changeIsEmpty_Test { assertFalse(GroupChangeUtil.changeIsEmpty(actions)); } + + @Test + public void not_empty_with_terminate_group_field_28() { + GroupChange.Actions actions = new GroupChange.Actions.Builder() + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } } diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java index da6b7cb414..2458cac7d1 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java @@ -53,7 +53,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -66,7 +66,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -79,7 +79,7 @@ public final class GroupChangeUtil_resolveConflict_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @@ -993,4 +993,53 @@ public final class GroupChangeUtil_resolveConflict_Test { GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); } + + @Test + public void field_28__terminate_group_preserved_when_group_not_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(false) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build()); + } + + @Test + public void field_28__terminate_group_removed_when_group_already_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(true) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + GroupChange.Actions conflictingActions = new GroupChange.Actions.Builder() + .version(6) + .terminate_group(new GroupChange.Actions.TerminateGroupAction()) + .build(); + + GroupChange.Actions expectedResolvedActions = new GroupChange.Actions.Builder() + .version(6) + .build(); + + assertEquals(expectedResolvedActions, GroupChangeUtil.resolveConflict(groupState, conflictingChange, conflictingActions).build()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java index 3d56c33425..a9d8a70b56 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java @@ -46,7 +46,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, maxFieldFound); + 28, maxFieldFound); } /** @@ -59,7 +59,7 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS); assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @@ -785,4 +785,43 @@ public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test { DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build(); assertTrue(DecryptedGroupExtensions.getChangedFields(resolvedChanges).isEmpty()); } + + @Test + public void field_28__terminate_group_preserved_when_group_not_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(false) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build()); + } + + @Test + public void field_28__terminate_group_removed_when_group_already_terminated() { + DecryptedGroup groupState = new DecryptedGroup.Builder() + .revision(5) + .terminated(true) + .build(); + + DecryptedGroupChange conflictingChange = new DecryptedGroupChange.Builder() + .revision(6) + .terminateGroup(true) + .build(); + + DecryptedGroupChange expectedResolvedChange = new DecryptedGroupChange.Builder() + .revision(6) + .build(); + + assertEquals(expectedResolvedChange, GroupChangeUtil.resolveConflict(groupState, conflictingChange).build()); + } } \ No newline at end of file diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java index f60c256262..b6f5dd7cf4 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java @@ -73,7 +73,7 @@ public final class GroupsV2Operations_decrypt_change_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("GroupV2Operations#decryptChange and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - 27, + 28, maxFieldFound); } @@ -544,6 +544,16 @@ public final class GroupsV2Operations_decrypt_change_Test { assertDecryption(encryptedChange, expectedDecryptedChange); } + @Test + public void can_pass_through_terminate_group_field_28() { + GroupChange.Actions.Builder encryptedChange = groupOperations.createTerminateGroup(); + + DecryptedGroupChange.Builder expectedDecryptedChange = new DecryptedGroupChange.Builder() + .terminateGroup(true); + + assertDecryption(encryptedChange, expectedDecryptedChange); + } + private static ProfileKey newProfileKey() { try { return new ProfileKey(Util.getSecretBytes(32)); diff --git a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java index 9ad701cb3f..55cbaddade 100644 --- a/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java +++ b/lib/libsignal-service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java @@ -58,7 +58,7 @@ public final class GroupsV2Operations_decrypt_group_Test { int maxFieldFound = getMaxDeclaredFieldNumber(Group.class); assertEquals("GroupOperations and its tests need updating to account for new fields on " + Group.class.getName(), - 13, maxFieldFound); + 14, maxFieldFound); } @Test @@ -310,6 +310,17 @@ public final class GroupsV2Operations_decrypt_group_Test { assertEquals(new DecryptedBannedMember.Builder().serviceIdBytes(member1.toByteString()).build(), decryptedGroup.bannedMembers.get(0)); } + @Test + public void pass_through_terminated_field_14() throws VerificationFailedException, InvalidGroupStateException { + Group group = new Group.Builder() + .terminated(true) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(true, decryptedGroup.terminated); + } + private ByteString encryptProfileKey(ACI aci, ProfileKey profileKey) { return ByteString.of(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, aci.getLibSignalAci()).serialize()); }