From 1ade8b502f49616511c5f4200726e8baf46b75f2 Mon Sep 17 00:00:00 2001 From: Clark Date: Mon, 26 Feb 2024 10:43:51 -0500 Subject: [PATCH] Convert and store new group changes in MessageExtras. --- .../v2/database/ChatItemExportIterator.kt | 42 +- .../v2/database/ChatItemImportInserter.kt | 13 + .../database/MessageTableBackupExtensions.kt | 3 +- .../securesms/database/MessageTable.kt | 7 +- .../securesms/database/ThreadTable.kt | 4 +- .../model/GroupsV2UpdateMessageConverter.kt | 681 ++++++++++++++++++ .../model/GroupsV2UpdateMessageProducer.java | 69 +- .../database/model/MessageRecord.java | 6 +- .../securesms/groups/GroupManagerV2.java | 9 +- .../securesms/groups/GroupProtoUtil.java | 19 + .../v2/processing/GroupsV2StateProcessor.java | 14 +- .../securesms/mms/IncomingMessage.kt | 10 +- .../securesms/mms/MessageGroupContext.java | 21 +- .../securesms/mms/OutgoingMessage.kt | 16 +- app/src/main/protowire/Backup.proto | 15 +- app/src/main/protowire/Database.proto | 5 +- .../GroupsV2UpdateMessageProducerTest.java | 55 +- 17 files changed, 933 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index b1c893ccf0..49b0eb6679 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -9,6 +9,7 @@ import android.database.Cursor import com.annimon.stream.Stream import okio.ByteString.Companion.toByteString import org.signal.core.util.Base64 +import org.signal.core.util.Base64.decode import org.signal.core.util.Base64.decodeOrThrow import org.signal.core.util.logging.Log import org.signal.core.util.requireBlob @@ -40,11 +41,15 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet import org.thoughtcrime.securesms.database.documents.NetworkFailureSet import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil +import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.util.JsonUtils import org.whispersystems.signalservice.api.push.ServiceId.ACI @@ -154,6 +159,26 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } ) } + MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> { + val groupChange = record.messageExtras?.gv2UpdateDescription?.groupChangeUpdate + if (groupChange != null) { + builder.updateMessage = ChatUpdateMessage( + groupChange = groupChange + ) + } else if (record.body != null) { + try { + val decoded: ByteArray = decode(record.body) + val context = DecryptedGroupV2Context.ADAPTER.decode(decoded) + builder.updateMessage = ChatUpdateMessage( + groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account().getServiceIds(), context) + ) + } catch (e: IOException) { + continue + } + } else { + continue + } + } MessageTypes.isCallLog(record.type) -> { val call = calls.getCallByMessageId(record.id) if (call != null) { @@ -412,6 +437,17 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } + private fun ByteArray?.parseMessageExtras(): MessageExtras? { + if (this == null) { + return null + } + return try { + MessageExtras.ADAPTER.decode(this) + } catch (e: java.lang.Exception) { + null + } + } + private fun Cursor.toBackupMessageRecord(): BackupMessageRecord { return BackupMessageRecord( id = this.requireLong(MessageTable.ID), @@ -443,7 +479,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP), networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(), identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(), - baseType = this.requireLong(COLUMN_BASE_TYPE) + baseType = this.requireLong(COLUMN_BASE_TYPE), + messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras() ) } @@ -477,6 +514,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: val read: Boolean, val networkFailureRecipientIds: Set, val identityMismatchRecipientIds: Set, - val baseType: Long + val baseType: Long, + val messageExtras: MessageExtras? ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 5c049409c9..bf0245b165 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -33,6 +33,8 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet import org.thoughtcrime.securesms.database.documents.NetworkFailure import org.thoughtcrime.securesms.database.documents.NetworkFailureSet import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent @@ -410,6 +412,17 @@ class ChatItemImportInserter( // Calls don't use the incoming/outgoing flags, so we overwrite the flags here this.put(MessageTable.TYPE, typeFlags) } + updateMessage.groupChange != null -> { + put(MessageTable.BODY, "") + put( + MessageTable.MESSAGE_EXTRAS, + MessageExtras( + gv2UpdateDescription = + GV2UpdateDescription(groupChangeUpdate = updateMessage.groupChange) + ).encode() + ) + typeFlags = MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT + } } this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt index 4b90c64955..5c483431d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt @@ -47,7 +47,8 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator { MessageTable.READ, MessageTable.NETWORK_FAILURES, MessageTable.MISMATCHED_IDENTITIES, - "${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}" + "${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}", + MessageTable.MESSAGE_EXTRAS ) .from(MessageTable.TABLE_NAME) .where( 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 ce9be8a6b8..b374010bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -2305,6 +2305,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID)) val messageRangesData = cursor.requireBlob(MESSAGE_RANGES) val scheduledDate = cursor.requireLong(SCHEDULED_DATE) + val messageExtrasBytes = cursor.requireBlob(MESSAGE_EXTRAS) + val messageExtras = if (messageExtrasBytes != null) MessageExtras.ADAPTER.decode(messageExtrasBytes) else null val quoteId = cursor.requireLong(QUOTE_ID) val quoteAuthor = cursor.requireLong(QUOTE_AUTHOR) @@ -2357,7 +2359,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat if (body != null && (MessageTypes.isGroupQuit(outboxType) || MessageTypes.isGroupUpdate(outboxType))) { OutgoingMessage.groupUpdateMessage( threadRecipient = threadRecipient, - groupContext = MessageGroupContext(body, MessageTypes.isGroupV2(outboxType)), + groupContext = if (messageExtras != null) MessageGroupContext(messageExtras, MessageTypes.isGroupV2(outboxType)) else MessageGroupContext(body, MessageTypes.isGroupV2(outboxType)), avatar = attachments, sentTimeMillis = timestamp, expiresIn = 0, @@ -2859,6 +2861,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat contentValues.put(PARENT_STORY_ID, if (message.parentStoryId != null) message.parentStoryId.serialize() else 0) contentValues.put(SCHEDULED_DATE, message.scheduledDate) contentValues.putNull(LATEST_REVISION_ID) + contentValues.put(MESSAGE_EXTRAS, message.messageExtras?.encode()) if (editedMessage != null) { contentValues.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id) @@ -5062,7 +5065,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val editCount = cursor.requireInt(REVISION_NUMBER) val isRead = cursor.requireBoolean(READ) val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS) - val messageExtras = if (messageExtraBytes != null) MessageExtras.ADAPTER.decode(messageExtraBytes) else null + val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) } if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { hasReadReceipt = false 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 95c03d96d8..656462ee29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -1929,7 +1929,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val hasReadReceipt = TextSecurePreferences.isReadReceiptsEnabled(context) && cursor.requireBoolean(HAS_READ_RECEIPT) val extraString = cursor.getString(cursor.getColumnIndexOrThrow(SNIPPET_EXTRAS)) - val messageExtras = cursor.getBlob(cursor.getColumnIndexOrThrow(SNIPPET_MESSAGE_EXTRAS)) + val messageExtraBytes = cursor.getBlob(cursor.getColumnIndexOrThrow(SNIPPET_MESSAGE_EXTRAS)) + val messageExtras = if (messageExtraBytes != null) MessageExtras.ADAPTER.decode(messageExtraBytes) else null val extra: Extra? = if (extraString != null) { try { val jsonObject = SaneJSONObject(JSONObject(extraString)) @@ -1974,6 +1975,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa .setPinned(cursor.requireBoolean(PINNED)) .setUnreadSelfMentionsCount(cursor.requireInt(UNREAD_SELF_MENTION_COUNT)) .setExtra(extra) + .setSnippetMessageExtras(messageExtras) .build() } 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 new file mode 100644 index 0000000000..a619abe627 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt @@ -0,0 +1,681 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.model + +import ProtoUtil.isNullOrEmpty +import okio.ByteString +import org.signal.core.util.StringUtil +import org.signal.storageservice.protos.groups.AccessControl +import org.signal.storageservice.protos.groups.AccessControl.AccessRequired +import org.signal.storageservice.protos.groups.Member +import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember +import org.signal.storageservice.protos.groups.local.EnabledState +import org.thoughtcrime.securesms.backup.v2.proto.GenericGroupUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupAdminStatusUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupAnnouncementOnlyChangeUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupAttributesAccessLevelChangeUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupAvatarUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupCreationUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupDescriptionUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupExpirationTimerUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationAcceptedUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationDeclinedUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationRevokedUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInviteLinkAdminApprovalUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInviteLinkDisabledUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInviteLinkEnabledUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupInviteLinkResetUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestApprovalUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestCanceledUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberAddedUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberJoinedByLinkUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberJoinedUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberLeftUpdate +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.GroupSequenceOfRequestsAndCancelsUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel +import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedOtherUserToGroupUpdate +import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedToGroupUpdate +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil +import org.whispersystems.signalservice.api.push.ServiceId.Companion.parseOrNull +import org.whispersystems.signalservice.api.push.ServiceIds +import org.whispersystems.signalservice.api.util.UuidUtil +import java.util.LinkedList +import java.util.Optional +import java.util.stream.Collectors + +/** + * Object to help with the translation between DecryptedGroupV2Context group updates + * and GroupChangeChatUpdates, which store the update messages as distinct messages rather + * than diffs of the group state. + */ +object GroupsV2UpdateMessageConverter { + + @JvmStatic + fun translateDecryptedChange(selfIds: ServiceIds, groupContext: DecryptedGroupV2Context): GroupChangeChatUpdate { + if (groupContext.change != null && ((groupContext.groupState != null && groupContext.groupState.revision != 0) || groupContext.previousGroupState != null)) { + return translateDecryptedChangeUpdate(selfIds, groupContext) + } else { + return translateDecryptedChangeNewGroup(selfIds, groupContext) + } + } + + @JvmStatic + fun translateDecryptedChangeNewGroup(selfIds: ServiceIds, groupContext: DecryptedGroupV2Context): GroupChangeChatUpdate { + var selfPending = Optional.empty() + val decryptedGroupChange = groupContext.change + val group = groupContext.groupState + val updates: MutableList = LinkedList() + + if (group != null) { + selfPending = DecryptedGroupUtil.findPendingByServiceId(group.pendingMembers, selfIds.aci) + if (selfPending.isEmpty() && selfIds.pni != null) { + selfPending = DecryptedGroupUtil.findPendingByServiceId(group.pendingMembers, selfIds.pni) + } + } + + if (selfPending.isPresent) { + updates.add( + GroupChangeChatUpdate.Update( + selfInvitedToGroupUpdate = SelfInvitedToGroupUpdate(inviterAci = selfPending.get().addedByAci) + ) + ) + return GroupChangeChatUpdate(updates = updates) + } + + if (decryptedGroupChange != null) { + val foundingMemberUuid: ByteString = decryptedGroupChange.editorServiceIdBytes + if (foundingMemberUuid.size > 0) { + if (selfIds.matches(foundingMemberUuid)) { + updates.add( + GroupChangeChatUpdate.Update( + groupCreationUpdate = GroupCreationUpdate(updaterAci = foundingMemberUuid) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberAddedUpdate = GroupMemberAddedUpdate(updaterAci = foundingMemberUuid, newMemberAci = selfIds.aci.toByteString()) + ) + ) + } + return GroupChangeChatUpdate(updates = updates) + } + } + + if (group != null && DecryptedGroupUtil.findMemberByAci(group.members, selfIds.aci).isPresent) { + updates.add(GroupChangeChatUpdate.Update(groupMemberJoinedUpdate = GroupMemberJoinedUpdate(newMemberAci = selfIds.aci.toByteString()))) + } + return GroupChangeChatUpdate(updates = updates) + } + + @JvmStatic + fun translateDecryptedChangeUpdate(selfIds: ServiceIds, groupContext: DecryptedGroupV2Context): GroupChangeChatUpdate { + var previousGroupState = groupContext.previousGroupState + val change = groupContext.change!! + if (DecryptedGroup().equals(previousGroupState)) { + previousGroupState = null + } + val updates: MutableList = LinkedList() + var editorUnknown = change.editorServiceIdBytes.size == 0 + val editorServiceId = if (editorUnknown) null else parseOrNull(change.editorServiceIdBytes) + if (editorServiceId == null || editorServiceId.isUnknown) { + editorUnknown = true + } + translateMemberAdditions(change, editorUnknown, updates) + translateModifyMemberRoles(change, editorUnknown, updates) + translateInvitations(selfIds, change, editorUnknown, updates) + translateRevokedInvitations(selfIds, change, editorUnknown, updates) + translatePromotePending(selfIds, change, editorUnknown, updates) + translateNewTitle(change, editorUnknown, updates) + translateNewDescription(change, editorUnknown, updates) + translateNewAvatar(change, editorUnknown, updates) + translateNewTimer(change, editorUnknown, updates) + translateNewAttributeAccess(change, editorUnknown, updates) + translateNewMembershipAccess(change, editorUnknown, updates) + translateNewGroupInviteLinkAccess(previousGroupState, change, editorUnknown, updates) + translateRequestingMembers(selfIds, change, editorUnknown, updates) + translateRequestingMemberApprovals(selfIds, change, editorUnknown, updates) + translateRequestingMemberDeletes(selfIds, change, editorUnknown, updates) + translateAnnouncementGroupChange(change, editorUnknown, updates) + translatePromotePendingPniAci(selfIds, change, editorUnknown, updates) + translateMemberRemovals(selfIds, change, editorUnknown, updates) + if (updates.isEmpty()) { + translateUnknownChange(change, editorUnknown, updates) + } + return GroupChangeChatUpdate(updates = updates) + } + + @JvmStatic + fun translateMemberAdditions(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + for (member in change.newMembers) { + if (!editorUnknown && member.aciBytes == change.editorServiceIdBytes) { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberJoinedByLinkUpdate = GroupMemberJoinedByLinkUpdate(newMemberAci = member.aciBytes) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberAddedUpdate = GroupMemberAddedUpdate( + updaterAci = if (editorUnknown) null else change.editorServiceIdBytes, + newMemberAci = member.aciBytes, + hadOpenInvitation = false + ) + ) + ) + } + } + } + + @JvmStatic + fun translateModifyMemberRoles(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + for (roleChange in change.modifyMemberRoles) { + updates.add( + GroupChangeChatUpdate.Update( + groupAdminStatusUpdate = GroupAdminStatusUpdate( + updaterAci = if (editorUnknown) null else change.editorServiceIdBytes, + memberAci = roleChange.aciBytes, + wasAdminStatusGranted = roleChange.role == Member.Role.ADMINISTRATOR + ) + ) + ) + } + } + + @JvmStatic + fun translateInvitations(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorIsYou = selfIds.matches(change.editorServiceIdBytes) + + var notYouInviteCount = 0 + for (invitee in change.newPendingMembers) { + val newMemberIsYou = selfIds.matches(invitee.serviceIdBytes) + if (newMemberIsYou) { + updates.add( + GroupChangeChatUpdate.Update( + selfInvitedToGroupUpdate = SelfInvitedToGroupUpdate( + inviterAci = if (editorUnknown) convertUnknownUUIDtoNull(invitee.addedByAci) else change.editorServiceIdBytes + ) + ) + ) + } else { + if (editorIsYou) { + updates.add(GroupChangeChatUpdate.Update(selfInvitedOtherUserToGroupUpdate = SelfInvitedOtherUserToGroupUpdate(inviteeServiceId = invitee.serviceIdBytes))) + } else { + notYouInviteCount++ + } + } + } + if (notYouInviteCount > 0) { + updates.add( + GroupChangeChatUpdate.Update( + groupUnknownInviteeUpdate = GroupUnknownInviteeUpdate( + inviterAci = if (editorUnknown) null else change.editorServiceIdBytes, + inviteeCount = notYouInviteCount + ) + ) + ) + } + } + + @JvmStatic + fun translateRevokedInvitations(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + + val revokedInvitees = LinkedList() + + for (invitee in change.deletePendingMembers) { + val decline = invitee.serviceIdBytes == editorAci + if (decline) { + updates.add( + GroupChangeChatUpdate.Update( + groupInvitationDeclinedUpdate = GroupInvitationDeclinedUpdate(inviteeAci = invitee.serviceIdBytes) + ) + ) + } else if (selfIds.matches(invitee.serviceIdBytes)) { + updates.add( + GroupChangeChatUpdate.Update( + groupSelfInvitationRevokedUpdate = GroupSelfInvitationRevokedUpdate(revokerAci = editorAci) + ) + ) + } else { + revokedInvitees.add( + GroupInvitationRevokedUpdate.Invitee( + inviteeAci = invitee.serviceIdBytes + ) + ) + } + } + + if (revokedInvitees.isNotEmpty()) { + updates.add( + GroupChangeChatUpdate.Update( + groupInvitationRevokedUpdate = GroupInvitationRevokedUpdate( + updaterAci = editorAci, + invitees = revokedInvitees + ) + ) + ) + } + } + + @JvmStatic + fun translatePromotePending(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + val editorIsYou = if (editorUnknown) false else selfIds.matches(editorAci) + + for (member in change.promotePendingMembers) { + val newMemberIsYou: Boolean = selfIds.matches(member.aciBytes) + if (editorIsYou) { + if (newMemberIsYou) { + updates.add( + GroupChangeChatUpdate.Update( + groupInvitationAcceptedUpdate = GroupInvitationAcceptedUpdate( + inviterAci = null, + newMemberAci = member.aciBytes + ) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberAddedUpdate = GroupMemberAddedUpdate( + updaterAci = editorAci, + newMemberAci = member.aciBytes, + hadOpenInvitation = true + ) + ) + ) + } + } else if (editorUnknown) { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberJoinedUpdate = GroupMemberJoinedUpdate( + newMemberAci = member.aciBytes + ) + ) + ) + } else if (member.aciBytes == change.editorServiceIdBytes) { + updates.add( + GroupChangeChatUpdate.Update( + groupInvitationAcceptedUpdate = GroupInvitationAcceptedUpdate( + inviterAci = null, + newMemberAci = member.aciBytes + ) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberAddedUpdate = GroupMemberAddedUpdate( + updaterAci = editorAci, + newMemberAci = member.aciBytes, + hadOpenInvitation = true + ) + ) + ) + } + } + } + + @JvmStatic + fun translateNewTitle(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newTitle != null) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + val newTitle = StringUtil.isolateBidi(change.newTitle?.value_) + updates.add( + GroupChangeChatUpdate.Update( + groupNameUpdate = GroupNameUpdate( + updaterAci = editorAci, + newGroupName = newTitle + ) + ) + ) + } + } + + @JvmStatic + fun translateNewDescription(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newDescription != null) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupDescriptionUpdate = GroupDescriptionUpdate( + updaterAci = editorAci, + newDescription = change.newDescription?.value_ + ) + ) + ) + } + } + + @JvmStatic + fun translateNewAvatar(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newAvatar != null) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupAvatarUpdate = GroupAvatarUpdate( + updaterAci = editorAci, + wasRemoved = change.newAvatar?.value_.isNullOrEmpty() + ) + ) + ) + } + } + + @JvmStatic + fun translateNewTimer(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newTimer != null) { + updates.add( + GroupChangeChatUpdate.Update( + groupExpirationTimerUpdate = GroupExpirationTimerUpdate( + expiresInMs = change.newTimer!!.duration * 1000, + updaterAci = if (editorUnknown) null else change.editorServiceIdBytes + ) + ) + ) + } + } + + @JvmStatic + fun translateNewAttributeAccess(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newAttributeAccess != AccessControl.AccessRequired.UNKNOWN) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupAttributesAccessLevelChangeUpdate = GroupAttributesAccessLevelChangeUpdate( + updaterAci = editorAci, + accessLevel = translateGv2AccessLevel(change.newAttributeAccess) + ) + ) + ) + } + } + + private fun translateGv2AccessLevel(accessRequired: AccessRequired): GroupV2AccessLevel { + return when (accessRequired) { + AccessRequired.ANY -> GroupV2AccessLevel.ANY + AccessRequired.MEMBER -> GroupV2AccessLevel.MEMBER + AccessRequired.ADMINISTRATOR -> GroupV2AccessLevel.ADMINISTRATOR + AccessRequired.UNSATISFIABLE -> GroupV2AccessLevel.UNSATISFIABLE + AccessRequired.UNKNOWN -> GroupV2AccessLevel.UNKNOWN + else -> GroupV2AccessLevel.UNKNOWN + } + } + + @JvmStatic + fun translateNewMembershipAccess(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newMemberAccess !== AccessRequired.UNKNOWN) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupMembershipAccessLevelChangeUpdate = GroupMembershipAccessLevelChangeUpdate( + updaterAci = editorAci, + accessLevel = translateGv2AccessLevel(change.newMemberAccess) + ) + ) + ) + } + } + + @JvmStatic + fun translateNewGroupInviteLinkAccess(previousGroupState: DecryptedGroup?, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + var previousAccessControl: AccessRequired? = null + + if (previousGroupState?.accessControl != null) { + previousAccessControl = previousGroupState.accessControl!!.addFromInviteLink + } + + var groupLinkEnabled = false + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + + when (change.newInviteLinkAccess) { + AccessRequired.ANY -> { + groupLinkEnabled = true + updates.add( + if (previousAccessControl == AccessControl.AccessRequired.ADMINISTRATOR) { + GroupChangeChatUpdate.Update( + groupInviteLinkAdminApprovalUpdate = GroupInviteLinkAdminApprovalUpdate( + updaterAci = editorAci, + linkRequiresAdminApproval = false + ) + ) + } else { + GroupChangeChatUpdate.Update( + groupInviteLinkEnabledUpdate = GroupInviteLinkEnabledUpdate( + updaterAci = editorAci, + linkRequiresAdminApproval = false + ) + ) + } + ) + } + AccessRequired.ADMINISTRATOR -> { + groupLinkEnabled = true + updates.add( + if (previousAccessControl == AccessControl.AccessRequired.ANY) { + GroupChangeChatUpdate.Update( + groupInviteLinkAdminApprovalUpdate = GroupInviteLinkAdminApprovalUpdate( + updaterAci = editorAci, + linkRequiresAdminApproval = true + ) + ) + } else { + GroupChangeChatUpdate.Update( + groupInviteLinkEnabledUpdate = GroupInviteLinkEnabledUpdate( + updaterAci = editorAci, + linkRequiresAdminApproval = true + ) + ) + } + ) + } + AccessRequired.UNSATISFIABLE -> { + updates.add( + GroupChangeChatUpdate.Update( + groupInviteLinkDisabledUpdate = GroupInviteLinkDisabledUpdate( + updaterAci = editorAci + ) + ) + ) + } + else -> {} + } + if (!groupLinkEnabled && change.newInviteLinkPassword.size > 0) { + updates.add( + GroupChangeChatUpdate.Update( + groupInviteLinkResetUpdate = GroupInviteLinkResetUpdate( + updaterAci = editorAci + ) + ) + ) + } + } + + @JvmStatic + fun translateRequestingMembers(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val deleteRequestingUuids: Set = HashSet(change.deleteRequestingMembers) + for (member in change.newRequestingMembers) { + val requestingMemberIsYou = selfIds.matches(member.aciBytes) + if (!requestingMemberIsYou && deleteRequestingUuids.contains(member.aciBytes)) { + updates.add( + GroupChangeChatUpdate.Update( + groupSequenceOfRequestsAndCancelsUpdate = GroupSequenceOfRequestsAndCancelsUpdate( + requestorAci = member.aciBytes, + count = change.deleteRequestingMembers.size + ) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupJoinRequestUpdate = GroupJoinRequestUpdate( + requestorAci = member.aciBytes + ) + ) + ) + } + } + } + + @JvmStatic + fun translateRequestingMemberApprovals(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + for (requestingMember in change.promoteRequestingMembers) { + updates.add( + GroupChangeChatUpdate.Update( + groupJoinRequestApprovalUpdate = GroupJoinRequestApprovalUpdate( + updaterAci = editorAci, + requestorAci = requestingMember.aciBytes, + wasApproved = true + ) + ) + ) + } + } + + @JvmStatic + fun translateRequestingMemberDeletes(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val newRequestingUuids = change.newRequestingMembers.stream().map { m: DecryptedRequestingMember -> m.aciBytes }.collect(Collectors.toSet()) + + val editorIsYou = selfIds.matches(change.editorServiceIdBytes) + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + for (requestingMember in change.deleteRequestingMembers) { + if (newRequestingUuids.contains(requestingMember)) { + continue + } + + val requestingMemberIsYou = selfIds.matches(requestingMember) + if ((requestingMemberIsYou && editorIsYou) || (change.editorServiceIdBytes == requestingMember)) { + updates.add( + GroupChangeChatUpdate.Update( + groupJoinRequestCanceledUpdate = GroupJoinRequestCanceledUpdate( + requestorAci = requestingMember + ) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupJoinRequestApprovalUpdate = GroupJoinRequestApprovalUpdate( + requestorAci = requestingMember, + updaterAci = editorAci, + wasApproved = false + ) + ) + ) + } + } + } + + @JvmStatic + fun translateAnnouncementGroupChange(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + if (change.newIsAnnouncementGroup == EnabledState.ENABLED || change.newIsAnnouncementGroup == EnabledState.DISABLED) { + val editorAci = if (editorUnknown) null else change.editorServiceIdBytes + updates.add( + GroupChangeChatUpdate.Update( + groupAnnouncementOnlyChangeUpdate = GroupAnnouncementOnlyChangeUpdate( + updaterAci = editorAci, + isAnnouncementOnly = change.newIsAnnouncementGroup == EnabledState.ENABLED + ) + ) + ) + } + } + + @JvmStatic + fun translatePromotePendingPniAci(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorIsYou = selfIds.matches(change.editorServiceIdBytes) + for (newMember in change.promotePendingPniAciMembers) { + if (editorUnknown) { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberJoinedUpdate = GroupMemberJoinedUpdate( + newMemberAci = newMember.aciBytes + ) + ) + ) + } else { + if ((selfIds.matches(newMember.aciBytes) && editorIsYou) || newMember.aciBytes == change.editorServiceIdBytes) { + updates.add( + GroupChangeChatUpdate.Update( + groupInvitationAcceptedUpdate = GroupInvitationAcceptedUpdate( + inviterAci = null, + newMemberAci = newMember.aciBytes + ) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberAddedUpdate = GroupMemberAddedUpdate( + newMemberAci = newMember.aciBytes, + updaterAci = change.editorServiceIdBytes, + hadOpenInvitation = true, + inviterAci = null + ) + ) + ) + } + } + } + } + + @JvmStatic + fun translateMemberRemovals(selfIds: ServiceIds, change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + val editorIsYou: Boolean = selfIds.matches(change.editorServiceIdBytes) + for (member in change.deleteMembers) { + val removedMemberIsYou: Boolean = selfIds.matches(member) + if ((editorIsYou && removedMemberIsYou) || member == change.editorServiceIdBytes) { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberLeftUpdate = GroupMemberLeftUpdate(aci = member) + ) + ) + } else { + updates.add( + GroupChangeChatUpdate.Update( + groupMemberRemovedUpdate = GroupMemberRemovedUpdate( + removerAci = if (editorUnknown) null else change.editorServiceIdBytes, + removedAci = member + ) + ) + ) + } + } + } + + @JvmStatic + fun translateUnknownChange(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList) { + updates.add( + GroupChangeChatUpdate.Update( + genericGroupUpdate = GenericGroupUpdate( + updaterAci = if (editorUnknown) null else change.editorServiceIdBytes + ) + ) + ) + } + + private fun convertUnknownUUIDtoNull(id: ByteString?): ByteString? { + if (id.isNullOrEmpty()) return null + val uuid = UuidUtil.fromByteStringOrUnknown(id) + + if (UuidUtil.UNKNOWN_UUID == uuid) return null + return id + } +} 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 f41da6c02d..24e72956b8 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 @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupAvatarUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupCreationUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupDescriptionUpdate; +import org.thoughtcrime.securesms.backup.v2.proto.GroupExpirationTimerUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationAcceptedUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationDeclinedUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupInvitationRevokedUpdate; @@ -44,11 +45,13 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestApprovalUpdate import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestCanceledUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupJoinRequestUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberAddedUpdate; +import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberJoinedByLinkUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberJoinedUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberLeftUpdate; 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.GroupUnknownInviteeUpdate; import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel; import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationDroppedMembersUpdate; @@ -57,6 +60,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationSelfInvitedUpd import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationUpdate; import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedOtherUserToGroupUpdate; import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedToGroupUpdate; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription; import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; @@ -145,6 +149,9 @@ final class GroupsV2UpdateMessageProducer { for (GroupChangeChatUpdate.Update update : groupUpdates) { describeUpdate(update, updates); } + if (updates.isEmpty()) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_group_updated), R.drawable.ic_update_group_16)); + } return updates; } @@ -210,6 +217,41 @@ final class GroupsV2UpdateMessageProducer { describeGroupV2MigrationInvitedMembersUpdate(update.groupV2MigrationInvitedMembersUpdate, updates); } else if (update.groupV2MigrationSelfInvitedUpdate != null) { describeGroupV2MigrationSelfInvitedUpdate(update.groupV2MigrationSelfInvitedUpdate, updates); + } else if (update.groupMemberJoinedByLinkUpdate != null) { + describeGroupMemberJoinedByLinkUpdate(update.groupMemberJoinedByLinkUpdate, updates); + } else if (update.groupExpirationTimerUpdate != null) { + describeGroupExpirationTimerUpdate(update.groupExpirationTimerUpdate, updates); + } else if (update.groupSelfInvitationRevokedUpdate != null) { + describeGroupSelfInvitationRevokedUpdate(update.groupSelfInvitationRevokedUpdate, updates); + } + } + + private void describeGroupSelfInvitationRevokedUpdate(@NonNull GroupSelfInvitationRevokedUpdate update, @NonNull List updates) { + if (update.revokerAci == null) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_an_admin_revoked_your_invitation_to_the_group), R.drawable.ic_update_group_decline_16)); + } else { + updates.add(updateDescription(R.string.MessageRecord_s_revoked_your_invitation_to_the_group, update.revokerAci, R.drawable.ic_update_group_decline_16)); + } + } + private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List updates) { + String time = ExpirationUtil.getExpirationDisplayValue(context, update.expiresInMs / 1000); + if (update.updaterAci == null) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time), R.drawable.ic_update_timer_16)); + } else { + boolean editorIsYou = selfIds.matches(update.updaterAci); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time), R.drawable.ic_update_timer_16)); + } else { + updates.add(updateDescription(R.string.MessageRecord_s_set_disappearing_message_time_to_s, update.updaterAci, time, R.drawable.ic_update_timer_16)); + } + } + } + + private void describeGroupMemberJoinedByLinkUpdate(@NonNull GroupMemberJoinedByLinkUpdate update, @NonNull List updates) { + if (selfIds.matches(update.newMemberAci)) { + updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group_via_the_group_link), R.drawable.ic_update_group_accept_16)); + } else { + updates.add(updateDescription(R.string.MessageRecord_s_joined_the_group_via_the_group_link, update.newMemberAci, R.drawable.ic_update_group_accept_16)); } } @@ -254,12 +296,10 @@ final class GroupsV2UpdateMessageProducer { } private void describeInviteLinkDisabledUpdate(@NonNull GroupInviteLinkDisabledUpdate update, @NonNull List updates) { - boolean editorIsYou = selfIds.matches(update.updaterAci); - if (update.updaterAci == null) { updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_off), R.drawable.ic_update_group_role_16)); } else { - if (editorIsYou) { + if (selfIds.matches(update.updaterAci)) { updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_off_the_group_link), R.drawable.ic_update_group_role_16)); } else { updates.add(updateDescription(R.string.MessageRecord_s_turned_off_the_group_link, update.updaterAci, R.drawable.ic_update_group_role_16)); @@ -268,7 +308,6 @@ final class GroupsV2UpdateMessageProducer { } private void describeInviteLinkEnabledUpdate(@NonNull GroupInviteLinkEnabledUpdate update, @NonNull List updates) { - boolean editorIsYou = selfIds.matches(update.updaterAci); if (update.updaterAci == null) { if (update.linkRequiresAdminApproval) { @@ -277,7 +316,7 @@ final class GroupsV2UpdateMessageProducer { updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_link_has_been_turned_on_with_admin_approval_off), R.drawable.ic_update_group_role_16)); } } else { - if (editorIsYou) { + if (selfIds.matches(update.updaterAci)) { if (update.linkRequiresAdminApproval) { updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_group_link_with_admin_approval_on), R.drawable.ic_update_group_role_16)); } else { @@ -366,7 +405,7 @@ final class GroupsV2UpdateMessageProducer { private void describeGroupInvitationRevokedUpdate(@NonNull GroupInvitationRevokedUpdate update, @NonNull List updates) { int revokedMeCount = 0; for (GroupInvitationRevokedUpdate.Invitee invitee : update.invitees) { - if (selfIds.matches(invitee.inviteeAci) || selfIds.matches(invitee.inviteePni)) { + if ((invitee.inviteeAci != null && selfIds.matches(invitee.inviteeAci)) || (invitee.inviteePni != null && selfIds.matches(invitee.inviteePni))) { revokedMeCount++; } } @@ -409,17 +448,21 @@ final class GroupsV2UpdateMessageProducer { } else { updates.add(updateDescription(R.string.MessageRecord_s_joined_the_group, update.newMemberAci, R.drawable.ic_update_group_add_16)); } - } else if (update.hadOpenInvitation) { - if (selfIds.matches(update.updaterAci)) { - updates.add(updateDescription(R.string.MessageRecord_you_added_invited_member_s, update.newMemberAci, R.drawable.ic_update_group_add_16)); - } else { - updates.add(updateDescription(R.string.MessageRecord_s_added_invited_member_s, update.updaterAci, update.newMemberAci, R.drawable.ic_update_group_add_16)); - } } else { if (newMemberIsYou) { updates.add(0, updateDescription(R.string.MessageRecord_s_added_you, update.updaterAci, R.drawable.ic_update_group_add_16)); + } else if (selfIds.matches(update.updaterAci)) { + if (update.hadOpenInvitation) { + updates.add(updateDescription(R.string.MessageRecord_you_added_invited_member_s, update.newMemberAci, R.drawable.ic_update_group_add_16)); + } else { + updates.add(updateDescription(R.string.MessageRecord_you_added_s, update.newMemberAci, R.drawable.ic_update_group_add_16)); + } } else { - updates.add(updateDescription(R.string.MessageRecord_s_added_s, update.updaterAci, update.newMemberAci, R.drawable.ic_update_group_add_16)); + if (update.hadOpenInvitation) { + updates.add(updateDescription(R.string.MessageRecord_s_added_invited_member_s, update.updaterAci, update.newMemberAci, R.drawable.ic_update_group_add_16)); + } else { + updates.add(updateDescription(R.string.MessageRecord_s_added_s, update.updaterAci, update.newMemberAci, R.drawable.ic_update_group_add_16)); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index afad456370..b1f5bce8ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -180,7 +180,11 @@ public abstract class MessageRecord extends DisplayRecord { public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context, @Nullable Consumer recipientClickHandler) { if (isGroupUpdate() && isGroupV2()) { - return getGv2ChangeDescription(context, getBody(), recipientClickHandler); + if (messageExtras != null) { + return getGv2ChangeDescription(context, messageExtras, recipientClickHandler); + } else { + return getGv2ChangeDescription(context, getBody(), recipientClickHandler); + } } else if (isGroupUpdate() && isOutgoing()) { return staticUpdateDescription(context.getString(R.string.MessageRecord_you_updated_group), R.drawable.ic_update_group_16); } else if (isGroupUpdate()) { 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 98a9922478..34972895b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper; import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; @@ -1295,10 +1296,10 @@ final class GroupManagerV2 { @Nullable GroupChange signedGroupChange, boolean sendToMembers) { - GroupId.V2 groupId = GroupId.v2(masterKey); - Recipient groupRecipient = Recipient.externalGroupExact(groupId); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); - OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, decryptedGroupV2Context, System.currentTimeMillis()); + GroupId.V2 groupId = GroupId.v2(masterKey); + Recipient groupRecipient = Recipient.externalGroupExact(groupId); + GV2UpdateDescription updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, groupMutation, signedGroupChange); + OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis()); DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java index 5b099dff15..fe6ef4edf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -10,7 +10,13 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription; +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup; @@ -45,6 +51,19 @@ public final class GroupProtoUtil { throw new GroupNotAMemberException(); } + public static GV2UpdateDescription createOutgoingGroupV2UpdateDescription(@NonNull GroupMasterKey masterKey, + @NonNull GroupMutation groupMutation, + @Nullable GroupChange signedServerChange) + { + DecryptedGroupV2Context groupV2Context = createDecryptedGroupV2Context(masterKey, groupMutation, signedServerChange); + GroupChangeChatUpdate update = GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), groupV2Context); + + return new GV2UpdateDescription.Builder() + .gv2ChangeDescription(groupV2Context) + .groupChangeUpdate(update) + .build(); + } + public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey, @NonNull GroupMutation groupMutation, @Nullable GroupChange signedServerChange) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 76c2d69772..110221a7fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -18,13 +18,16 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; import org.thoughtcrime.securesms.database.model.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupDoesNotExistException; import org.thoughtcrime.securesms.groups.GroupId; @@ -573,8 +576,8 @@ public class GroupsV2StateProcessor { .deleteMembers(Collections.singletonList(serviceIds.getAci().toByteString())) .build(); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null); - OutgoingMessage leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, decryptedGroupV2Context, System.currentTimeMillis()); + GV2UpdateDescription updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null); + OutgoingMessage leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis()); try { MessageTable mmsDatabase = SignalDatabase.messages(); @@ -803,13 +806,18 @@ public class GroupsV2StateProcessor { boolean outgoing = !editor.isPresent() || aci.equals(editor.get()); + GV2UpdateDescription updateDescription = new GV2UpdateDescription.Builder() + .gv2ChangeDescription(decryptedGroupV2Context) + .groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context)) + .build(); + if (outgoing) { try { MessageTable mmsDatabase = SignalDatabase.messages(); ThreadTable threadTable = SignalDatabase.threads(); RecipientId recipientId = recipientTable.getOrInsertFromGroupId(groupId); Recipient recipient = Recipient.resolved(recipientId); - OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, decryptedGroupV2Context, timestamp); + OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, updateDescription, timestamp); long threadId = threadTable.getOrCreateThreadIdFor(recipient); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); 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 19b7b8a38c..4d947da7de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt @@ -8,7 +8,9 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.recipients.RecipientId @@ -36,7 +38,8 @@ class IncomingMessage( sharedContacts: List = emptyList(), linkPreviews: List = emptyList(), mentions: List = emptyList(), - val giftBadge: GiftBadge? = null + val giftBadge: GiftBadge? = null, + val messageExtras: MessageExtras? = null ) { val attachments: List = ArrayList(attachments) @@ -104,9 +107,8 @@ class IncomingMessage( serverTimeMillis = timestamp, groupId = groupId, groupContext = messageGroupContext, - serverGuid = serverGuid, - body = messageGroupContext.encodedGroupContext, - type = MessageType.GROUP_UPDATE + type = MessageType.GROUP_UPDATE, + messageExtras = MessageExtras(gv2UpdateDescription = GV2UpdateDescription(gv2ChangeDescription = groupContext, groupChangeUpdate = null)) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java index 3d0dfd34c5..ae252aed23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MessageGroupContext.java @@ -11,6 +11,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras; import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -30,7 +31,6 @@ import java.util.List; */ public final class MessageGroupContext { - @NonNull private final String encodedGroupContext; @NonNull private final GroupProperties group; @Nullable private final GroupV1Properties groupV1; @Nullable private final GroupV2Properties groupV2; @@ -38,7 +38,6 @@ public final class MessageGroupContext { public MessageGroupContext(@NonNull String encodedGroupContext, boolean v2) throws IOException { - this.encodedGroupContext = encodedGroupContext; if (v2) { this.groupV1 = null; this.groupV2 = new GroupV2Properties(DecryptedGroupV2Context.ADAPTER.decode(Base64.decode(encodedGroupContext))); @@ -50,15 +49,25 @@ public final class MessageGroupContext { } } + public MessageGroupContext(@NonNull MessageExtras messageExtras, boolean v2) { + if (v2) { + this.groupV1 = null; + this.groupV2 = new GroupV2Properties(messageExtras.gv2UpdateDescription.gv2ChangeDescription); + this.group = groupV2; + } else { + this.groupV1 = new GroupV1Properties(messageExtras.gv1Context); + this.groupV2 = null; + this.group = groupV1; + } + } + public MessageGroupContext(@NonNull GroupContext group) { - this.encodedGroupContext = Base64.encodeWithPadding(group.encode()); this.groupV1 = new GroupV1Properties(group); this.groupV2 = null; this.group = groupV1; } public MessageGroupContext(@NonNull DecryptedGroupV2Context group) { - this.encodedGroupContext = Base64.encodeWithPadding(group.encode()); this.groupV1 = null; this.groupV2 = new GroupV2Properties(group); this.group = groupV2; @@ -82,10 +91,6 @@ public final class MessageGroupContext { return groupV2 != null; } - public @NonNull String getEncodedGroupContext() { - return encodedGroupContext; - } - public String getName() { return group.getName(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index 79a7e159a8..3859640d4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -9,8 +9,9 @@ import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.ParentStoryId import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList -import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil @@ -52,7 +53,8 @@ data class OutgoingMessage( val scheduledDate: Long = -1, val messageToEdit: Long = 0, val isReportSpam: Boolean = false, - val isMessageRequestAccept: Boolean = false + val isMessageRequestAccept: Boolean = false, + val messageExtras: MessageExtras? = null ) { val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext) @@ -228,17 +230,18 @@ data class OutgoingMessage( * Helper for creating a group update message when a state change occurs and needs to be sent to others. */ @JvmStatic - fun groupUpdateMessage(threadRecipient: Recipient, group: DecryptedGroupV2Context, sentTimeMillis: Long): OutgoingMessage { - val groupContext = MessageGroupContext(group) + fun groupUpdateMessage(threadRecipient: Recipient, update: GV2UpdateDescription, sentTimeMillis: Long): OutgoingMessage { + val messageExtras = MessageExtras(gv2UpdateDescription = update) + val groupContext = MessageGroupContext(update.gv2ChangeDescription!!) return OutgoingMessage( threadRecipient = threadRecipient, - body = groupContext.encodedGroupContext, sentTimeMillis = sentTimeMillis, messageGroupContext = groupContext, isGroup = true, isGroupUpdate = true, - isSecure = true + isSecure = true, + messageExtras = messageExtras ) } @@ -260,7 +263,6 @@ data class OutgoingMessage( ): OutgoingMessage { return OutgoingMessage( threadRecipient = threadRecipient, - body = groupContext.encodedGroupContext, isGroup = true, isGroupUpdate = true, messageGroupContext = groupContext, diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 0299d77ffa..f45c085930 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -52,7 +52,7 @@ message AccountData { bool linkPreviews = 5; bool notDiscoverableByPhoneNumber = 6; bool preferContactAvatars = 7; - uint32 universalExpireTimer = 8; + uint32 universalExpireTimer = 8; // 0 means no universal expire timer. repeated string preferredReactionEmoji = 9; bool displayBadgesOnProfile = 10; bool keepMutedChatsArchived = 11; @@ -132,7 +132,7 @@ message Chat { uint64 recipientId = 2; bool archived = 3; uint32 pinnedOrder = 4; // 0 = unpinned, otherwise chat is considered pinned and will be displayed in ascending order - uint64 expirationTimerMs = 5; + uint64 expirationTimerMs = 5; // 0 = no expire timer. uint64 muteUntilMs = 6; bool markedUnread = 7; bool dontNotifyForMentionsIfMuted = 8; @@ -537,8 +537,10 @@ message SimpleChatUpdate { Type type = 1; } +// For 1:1 chat updates only. +// For group thread updates use GroupExpirationTimerUpdate. message ExpirationTimerChatUpdate { - uint32 expiresInMs = 1; + uint32 expiresInMs = 1; // 0 means the expiration timer was disabled } message ProfileChangeChatUpdate { @@ -591,6 +593,7 @@ message GroupChangeChatUpdate { GroupV2MigrationInvitedMembersUpdate groupV2MigrationInvitedMembersUpdate = 31; GroupV2MigrationDroppedMembersUpdate groupV2MigrationDroppedMembersUpdate = 32; GroupSequenceOfRequestsAndCancelsUpdate groupSequenceOfRequestsAndCancelsUpdate = 33; + GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34; } } @@ -794,6 +797,12 @@ message GroupV2MigrationDroppedMembersUpdate { int32 droppedMembersCount = 1; } +// For 1:1 timer updates, use ExpirationTimerChatUpdate. +message GroupExpirationTimerUpdate { + uint32 expiresInMs = 1; // 0 means the expiration timer was disabled + optional bytes updaterAci = 2; +} + message StickerPack { bytes id = 1; bytes key = 2; diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index a80304067b..7d7b23edd8 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -376,7 +376,10 @@ message ExternalLaunchTransactionState { } message MessageExtras { - GV2UpdateDescription gv2UpdateDescription = 1; + oneof extra { + GV2UpdateDescription gv2UpdateDescription = 1; + signalservice.GroupContext gv1Context = 2; + } } message GV2UpdateDescription { diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java index 72cd7a2048..951ae30983 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java @@ -24,6 +24,8 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.push.ServiceId; @@ -34,6 +36,7 @@ import org.whispersystems.signalservice.api.push.ServiceIds; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.ListIterator; import java.util.UUID; import java.util.stream.Collectors; @@ -62,6 +65,8 @@ public final class GroupsV2UpdateMessageProducerTest { private ACI alice; private ACI bob; + private ServiceIds selfIds; + private GroupsV2UpdateMessageProducer producer; @Rule @@ -79,6 +84,8 @@ public final class GroupsV2UpdateMessageProducerTest { alice = ACI.from(UUID.randomUUID()); bob = ACI.from(UUID.randomUUID()); + selfIds = new ServiceIds(you, PNI.from(UUID.randomUUID())); + recipientIdMockedStatic.when(() -> RecipientId.from(anyLong())).thenCallRealMethod(); RecipientId aliceId = RecipientId.from(1); @@ -87,7 +94,7 @@ public final class GroupsV2UpdateMessageProducerTest { Recipient aliceRecipient = recipientWithName(aliceId, "Alice"); Recipient bobRecipient = recipientWithName(bobId, "Bob"); - producer = new GroupsV2UpdateMessageProducer(ApplicationProvider.getApplicationContext(), new ServiceIds(you, PNI.from(UUID.randomUUID())), null); + producer = new GroupsV2UpdateMessageProducer(ApplicationProvider.getApplicationContext(), selfIds, null); recipientIdMockedStatic.when(() -> RecipientId.from(alice)).thenReturn(aliceId); recipientIdMockedStatic.when(() -> RecipientId.from(bob)).thenReturn(bobId); @@ -1422,6 +1429,27 @@ public final class GroupsV2UpdateMessageProducerTest { assertEquals("Alice said hello to Bob, and Bob said hello back to Alice.", result.toString()); } + private @NonNull String describeConvertedNewGroup(@NonNull DecryptedGroup groupState, @NonNull DecryptedGroupChange groupChange) { + GroupChangeChatUpdate update = GroupsV2UpdateMessageConverter.translateDecryptedChangeNewGroup(selfIds, new DecryptedGroupV2Context.Builder() + .change(groupChange) + .groupState(groupState) + .build()); + + return producer.describeChanges(update.updates).get(0).getSpannable().toString(); + } + + private @NonNull List describeConvertedChange(@Nullable DecryptedGroup previousGroupState, @NonNull DecryptedGroupChange change) { + GroupChangeChatUpdate update = GroupsV2UpdateMessageConverter.translateDecryptedChangeUpdate(selfIds, new DecryptedGroupV2Context.Builder() + .change(change) + .previousGroupState(previousGroupState) + .build()); + + return Stream.of(producer.describeChanges(update.updates)) + .map(UpdateDescription::getSpannable) + .map(Spannable::toString) + .toList(); + } + private @NonNull List describeChange(@NonNull DecryptedGroupChange change) { return describeChange(null, change); } @@ -1429,10 +1457,20 @@ public final class GroupsV2UpdateMessageProducerTest { private @NonNull List describeChange(@Nullable DecryptedGroup previousGroupState, @NonNull DecryptedGroupChange change) { - return Stream.of(producer.describeChanges(previousGroupState, change)) - .map(UpdateDescription::getSpannable) - .map(Spannable::toString) - .toList(); + List convertedChange = describeConvertedChange(previousGroupState, change); + List describedChange = Stream.of(producer.describeChanges(previousGroupState, change)) + .map(UpdateDescription::getSpannable) + .map(Spannable::toString) + .toList(); + assertEquals(describedChange.size(), convertedChange.size()); + + ListIterator convertedIterator = convertedChange.listIterator(); + ListIterator describedIterator = describedChange.listIterator(); + + while (convertedIterator.hasNext()) { + assertEquals(describedIterator.next(), convertedIterator.next()); + } + return describedChange; } private @NonNull String describeNewGroup(@NonNull DecryptedGroup group) { @@ -1440,7 +1478,12 @@ public final class GroupsV2UpdateMessageProducerTest { } private @NonNull String describeNewGroup(@NonNull DecryptedGroup group, @NonNull DecryptedGroupChange groupChange) { - return producer.describeNewGroup(group, groupChange).getSpannable().toString(); + String newGroupString = producer.describeNewGroup(group, groupChange).getSpannable().toString(); + String convertedGroupString = describeConvertedNewGroup(group, groupChange); + + assertEquals(newGroupString, convertedGroupString); + + return newGroupString; } private static GroupStateBuilder newGroupBy(ACI foundingMember, int revision) {