package org.thoughtcrime.securesms.groups; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.GroupChange; 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.DecryptedGroupJoinInfo; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.groups.GroupSecretParams; import org.signal.zkgroup.groups.UuidCiphertext; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper; import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword; import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob; import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.sms.MessageSender; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.GroupCandidate; import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.IOException; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; final class GroupManagerV2 { private static final String TAG = Log.tag(GroupManagerV2.class); private final Context context; private final GroupDatabase groupDatabase; private final GroupsV2Api groupsV2Api; private final GroupsV2Operations groupsV2Operations; private final GroupsV2Authorization authorization; private final GroupsV2StateProcessor groupsV2StateProcessor; private final UUID selfUuid; private final GroupCandidateHelper groupCandidateHelper; GroupManagerV2(@NonNull Context context) { this.context = context; this.groupDatabase = DatabaseFactory.getGroupDatabase(context); this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); this.groupsV2Operations = ApplicationDependencies.getGroupsV2Operations(); this.authorization = ApplicationDependencies.getGroupsV2Authorization(); this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor(); this.selfUuid = Recipient.self().getUuid().get(); this.groupCandidateHelper = new GroupCandidateHelper(context); } @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password) throws IOException, VerificationFailedException, GroupLinkNotActiveException { GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); return groupsV2Api.getGroupJoinInfo(groupSecretParams, Optional.fromNullable(password).transform(GroupLinkPassword::serialize), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); } @WorkerThread GroupCreator create() throws GroupChangeBusyException { return new GroupCreator(GroupsV2ProcessingLock.acquireGroupProcessingLock()); } @WorkerThread GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); } @WorkerThread GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException { return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock()); } @WorkerThread GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context) .requireGroup(groupId) .requireV2GroupProperties() .getGroupMasterKey(); return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock()); } @WorkerThread GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException { return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); } final class GroupCreator extends LockOwner { GroupCreator(@NonNull Closeable lock) { super(lock); } @WorkerThread @NonNull GroupManager.GroupActionResult createGroup(@NonNull Collection members, @Nullable String name, @Nullable byte[] avatar) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception { if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(members)) { throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities"); } GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); Set candidates = new HashSet<>(groupCandidateHelper.recipientIdsToCandidates(members)); if (SignalStore.internalValues().gv2ForceInvites()) { candidates = GroupCandidate.withoutProfileKeyCredentials(candidates); } if (!self.hasProfileKeyCredential()) { Log.w(TAG, "Cannot create a V2 group as self does not have a versioned profile"); throw new MembershipNotSuitableForV2Exception("Cannot create a V2 group as self does not have a versioned profile"); } GroupsV2Operations.NewGroup newGroup = groupsV2Operations.createNewGroup(name, Optional.fromNullable(avatar), self, candidates); GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams(); GroupMasterKey masterKey = groupSecretParams.getMasterKey(); try { groupsV2Api.putNewGroup(newGroup, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); DecryptedGroup decryptedGroup = groupsV2Api.getGroup(groupSecretParams, ApplicationDependencies.getGroupsV2Authorization().getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); if (decryptedGroup == null) { throw new GroupChangeFailedException(); } GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup); RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient groupRecipient = Recipient.resolved(groupRecipientId); AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); groupDatabase.onAvatarUpdated(groupId, avatar != null); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder(GroupChangeReconstruct.reconstructGroupChange(DecryptedGroup.newBuilder().build(), decryptedGroup)) .setEditor(UuidUtil.toByteString(selfUuid)) .build(); RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, decryptedGroup.getMembersCount() - 1, getPendingMemberRecipientIds(decryptedGroup.getPendingMembersList())); } catch (VerificationFailedException | InvalidGroupStateException e) { throw new GroupChangeFailedException(e); } } } final class GroupEditor extends LockOwner { private final GroupId.V2 groupId; private final GroupMasterKey groupMasterKey; private final GroupSecretParams groupSecretParams; private final GroupsV2Operations.GroupOperations groupOperations; GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) { super(lock); GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); this.groupId = groupId; this.groupMasterKey = v2GroupProperties.getGroupMasterKey(); this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); this.groupOperations = groupsV2Operations.forGroup(groupSecretParams); } @WorkerThread @NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection newMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception { if (!GroupsV2CapabilityChecker.allSupportGroupsV2AndUuid(newMembers)) { throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities"); } Set groupCandidates = groupCandidateHelper.recipientIdsToCandidates(new HashSet<>(newMembers)); if (SignalStore.internalValues().gv2ForceInvites()) { groupCandidates = GroupCandidate.withoutProfileKeyCredentials(groupCandidates); } return commitChangeWithConflictResolution(groupOperations.createModifyGroupMembershipChange(groupCandidates, selfUuid)); } @WorkerThread @NonNull GroupManager.GroupActionResult updateGroupTimer(int expirationTime) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { return commitChangeWithConflictResolution(groupOperations.createModifyGroupTimerChange(expirationTime)); } @WorkerThread @NonNull GroupManager.GroupActionResult updateAttributesRights(@NonNull GroupAccessControl newRights) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { return commitChangeWithConflictResolution(groupOperations.createChangeAttributesRights(rightsToAccessControl(newRights))); } @WorkerThread @NonNull GroupManager.GroupActionResult updateMembershipRights(@NonNull GroupAccessControl newRights) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { return commitChangeWithConflictResolution(groupOperations.createChangeMembershipRights(rightsToAccessControl(newRights))); } @WorkerThread @NonNull GroupManager.GroupActionResult updateGroupTitleAndAvatar(@Nullable String title, @Nullable byte[] avatarBytes, boolean avatarChanged) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { try { GroupChange.Actions.Builder change = title != null ? groupOperations.createModifyGroupTitle(title) : GroupChange.Actions.newBuilder(); if (avatarChanged) { String cdnKey = avatarBytes != null ? groupsV2Api.uploadAvatar(avatarBytes, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams)) : ""; change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder() .setAvatar(cdnKey)); } GroupManager.GroupActionResult groupActionResult = commitChangeWithConflictResolution(change); if (avatarChanged) { AvatarHelper.setAvatar(context, Recipient.externalGroup(context, groupId).getId(), avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); groupDatabase.onAvatarUpdated(groupId, avatarBytes != null); } return groupActionResult; } catch (VerificationFailedException e) { throw new GroupChangeFailedException(e); } } @WorkerThread @NonNull GroupManager.GroupActionResult revokeInvites(@NonNull Collection uuidCipherTexts) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts))); } @WorkerThread @NonNull GroupManager.GroupActionResult approveRequests(@NonNull Collection recipientIds) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { Set uuids = Stream.of(recipientIds) .map(r -> Recipient.resolved(r).getUuid().get()) .collect(Collectors.toSet()); return commitChangeWithConflictResolution(groupOperations.createApproveGroupJoinRequest(uuids)); } @WorkerThread @NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection recipientIds) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { Set uuids = Stream.of(recipientIds) .map(r -> Recipient.resolved(r).getUuid().get()) .collect(Collectors.toSet()); return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids)); } @WorkerThread @NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId, boolean admin) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { Recipient recipient = Recipient.resolved(recipientId); return commitChangeWithConflictResolution(groupOperations.createChangeMemberRole(recipient.getUuid().get(), admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT)); } @WorkerThread @NonNull GroupManager.GroupActionResult leaveGroup() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { Recipient self = Recipient.self(); GroupDatabase.GroupRecord groupRecord = groupDatabase.getGroup(groupId).get(); List pendingMembersList = groupRecord.requireV2GroupProperties().getDecryptedGroup().getPendingMembersList(); Optional selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid); if (selfPendingMember.isPresent()) { try { return revokeInvites(Collections.singleton(new UuidCiphertext(selfPendingMember.get().getUuidCipherText().toByteArray()))); } catch (InvalidInputException e) { throw new AssertionError(e); } } else { return ejectMember(self.getId()); } } @WorkerThread @NonNull GroupManager.GroupActionResult ejectMember(@NonNull RecipientId recipientId) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { Recipient recipient = Recipient.resolved(recipientId); return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.getUuid().get()))); } @WorkerThread @NonNull GroupManager.GroupActionResult addMemberAdminsAndLeaveGroup(Collection newAdmins) throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { Recipient self = Recipient.self(); List newAdminRecipients = Stream.of(newAdmins).map(id -> Recipient.resolved(id).getUuid().get()).toList(); return commitChangeWithConflictResolution(groupOperations.createLeaveAndPromoteMembersToAdmin(self.getUuid().get(), newAdminRecipients)); } @WorkerThread @Nullable GroupManager.GroupActionResult updateSelfProfileKeyInGroup() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); Optional selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid); if (!selfInGroup.isPresent()) { Log.w(TAG, "Self not in group " + groupId); return null; } if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) { Log.i(TAG, "Own Profile Key is already up to date in group " + groupId); return null; } GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); if (!groupCandidate.hasProfileKeyCredential()) { Log.w(TAG, "No credential available"); return null; } return commitChangeWithConflictResolution(groupOperations.createUpdateProfileKeyCredentialChange(groupCandidate.getProfileKeyCredential().get())); } @WorkerThread @Nullable GroupManager.GroupActionResult acceptInvite() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); Optional selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), Recipient.self().getUuid().get()); if (selfInGroup.isPresent()) { Log.w(TAG, "Self already in group"); return null; } GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); if (!groupCandidate.hasProfileKeyCredential()) { Log.w(TAG, "No credential available"); return null; } return commitChangeWithConflictResolution(groupOperations.createAcceptInviteChange(groupCandidate.getProfileKeyCredential().get())); } @WorkerThread public GroupManager.GroupActionResult cycleGroupLinkPassword() throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { return commitChangeWithConflictResolution(groupOperations.createModifyGroupLinkPasswordChange(GroupLinkPassword.createNew().serialize())); } @WorkerThread public GroupManager.GroupActionResult setJoinByGroupLinkState(@NonNull GroupManager.GroupLinkState state) throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { AccessControl.AccessRequired access; switch (state) { case DISABLED : access = AccessControl.AccessRequired.UNSATISFIABLE; break; case ENABLED : access = AccessControl.AccessRequired.ANY; break; case ENABLED_WITH_APPROVAL: access = AccessControl.AccessRequired.ADMINISTRATOR; break; default: throw new AssertionError(); } GroupChange.Actions.Builder change = groupOperations.createChangeJoinByLinkRights(access); if (state != GroupManager.GroupLinkState.DISABLED) { DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); if (group.getInviteLinkPassword().isEmpty()) { Log.d(TAG, "First time enabling group links for group and password empty, generating"); change = groupOperations.createModifyGroupLinkPasswordAndRightsChange(GroupLinkPassword.createNew().serialize(), access); } } return commitChangeWithConflictResolution(change); } private @NonNull GroupManager.GroupActionResult commitChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change) throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get())); for (int attempt = 0; attempt < 5; attempt++) { try { return commitChange(change); } catch (GroupPatchNotAcceptedException e) { throw new GroupChangeFailedException(e); } catch (ConflictException e) { Log.w(TAG, "Invalid group patch or conflict", e); change = resolveConflict(change); if (GroupChangeUtil.changeIsEmpty(change.build())) { Log.i(TAG, "Change is empty after conflict resolution"); Recipient groupRecipient = Recipient.externalGroup(context, groupId); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); return new GroupManager.GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); } } } throw new GroupChangeFailedException("Unable to apply change to group after conflicts"); } private GroupChange.Actions.Builder resolveConflict(@NonNull GroupChange.Actions.Builder change) throws IOException, GroupNotAMemberException, GroupChangeFailedException { GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey) .updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null); if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) { throw new GroupChangeFailedException(); } Log.w(TAG, "Group has been updated"); try { GroupChange.Actions changeActions = change.build(); return GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(), groupOperations.decryptChange(changeActions, selfUuid), changeActions); } catch (VerificationFailedException | InvalidGroupStateException ex) { throw new GroupChangeFailedException(ex); } } private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); final int nextRevision = v2GroupProperties.getGroupRevision() + 1; final GroupChange.Actions changeActions = change.setRevision(nextRevision).build(); final DecryptedGroupChange decryptedChange; final DecryptedGroup decryptedGroupState; final DecryptedGroup previousGroupState; try { previousGroupState = v2GroupProperties.getDecryptedGroup(); decryptedChange = groupOperations.decryptChange(changeActions, selfUuid); decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { Log.w(TAG, e); throw new IOException(e); } GroupChange signedGroupChange = commitToServer(changeActions); groupDatabase.update(groupId, decryptedGroupState); GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState); RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange); int newMembersCount = decryptedChange.getNewMembersCount(); List newPendingMembers = getPendingMemberRecipientIds(decryptedChange.getNewPendingMembersList()); return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, newMembersCount, newPendingMembers); } private @NonNull GroupChange commitToServer(@NonNull GroupChange.Actions change) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { try { return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent()); } catch (NotInGroupException e) { Log.w(TAG, e); throw new GroupNotAMemberException(e); } catch (AuthorizationFailedException e) { Log.w(TAG, e); throw new GroupInsufficientRightsException(e); } catch (VerificationFailedException e) { Log.w(TAG, e); throw new GroupChangeFailedException(e); } } } final class GroupUpdater extends LockOwner { private final GroupMasterKey groupMasterKey; GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) { super(lock); this.groupMasterKey = groupMasterKey; } @WorkerThread void updateLocalToServerRevision(int revision, long timestamp, @Nullable byte[] signedGroupChange) throws IOException, GroupNotAMemberException { new GroupsV2StateProcessor(context).forGroup(groupMasterKey) .updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange)); } private DecryptedGroupChange getDecryptedGroupChange(@Nullable byte[] signedGroupChange) { if (signedGroupChange != null) { GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); try { return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true) .orNull(); } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) { Log.w(TAG, "Unable to verify supplied group change", e); } } return null; } } final class GroupJoiner extends LockOwner { private final GroupId.V2 groupId; private final GroupLinkPassword password; private final GroupSecretParams groupSecretParams; private final GroupsV2Operations.GroupOperations groupOperations; private final GroupMasterKey groupMasterKey; public GroupJoiner(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password, @NonNull Closeable lock) { super(lock); this.groupId = GroupId.v2(groupMasterKey); this.password = password; this.groupMasterKey = groupMasterKey; this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); this.groupOperations = groupsV2Operations.forGroup(groupSecretParams); } @WorkerThread public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo, @Nullable byte[] avatar) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException { boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR; boolean alreadyAMember = false; if (requestToJoin) { Log.i(TAG, "Requesting to join " + groupId); } else { Log.i(TAG, "Joining " + groupId); } GroupChange signedGroupChange = null; DecryptedGroupChange decryptedChange = null; try { signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision()); if (requestToJoin) { Log.i(TAG, String.format("Successfully requested to join %s on server", groupId)); } else { Log.i(TAG, String.format("Successfully added self to %s on server", groupId)); } decryptedChange = decryptChange(signedGroupChange); } catch (GroupJoinAlreadyAMemberException e) { Log.i(TAG, "Server reports that we are already a member of " + groupId); alreadyAMember = true; } DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin); Optional group = groupDatabase.getGroup(groupId); if (group.isPresent()) { Log.i(TAG, "Group already present locally"); DecryptedGroup currentGroupState = group.get() .requireV2GroupProperties() .getDecryptedGroup(); DecryptedGroup updatedGroup = currentGroupState; try { if (decryptedChange != null) { updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange); } updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision()); } catch (NotAbleToApplyGroupV2ChangeException e) { Log.w(TAG, e); updatedGroup = decryptedGroup; } groupDatabase.update(groupId, updatedGroup); } else { groupDatabase.create(groupMasterKey, decryptedGroup); Log.i(TAG, "Created local group with placeholder"); } RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); Recipient groupRecipient = Recipient.resolved(groupRecipientId); AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); groupDatabase.onAvatarUpdated(groupId, avatar != null); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true); if (alreadyAMember) { Log.i(TAG, "Already a member of the group"); ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1); return new GroupManager.GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); } else if (requestToJoin) { Log.i(TAG, "Requested to join, cannot send update"); RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); return new GroupManager.GroupActionResult(groupRecipient, recipientAndThread.threadId, 0, Collections.emptyList()); } else { Log.i(TAG, "Joined group on server, fetching group state and sending update"); return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange); } } private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient, @NonNull DecryptedGroup decryptedGroup, @NonNull DecryptedGroupChange decryptedChange, @NonNull GroupChange signedGroupChange) throws GroupChangeFailedException, IOException { try { new GroupsV2StateProcessor(context).forGroup(groupMasterKey) .updateLocalGroupToRevision(decryptedChange.getRevision(), System.currentTimeMillis(), decryptedChange); RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, new GroupMutation(null, decryptedChange, decryptedGroup), signedGroupChange); return new GroupManager.GroupActionResult(groupRecipient, recipientAndThread.threadId, 1, Collections.emptyList()); } catch (GroupNotAMemberException e) { Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e); ApplicationDependencies.getJobManager() .add(new RequestGroupV2InfoJob(groupId)); throw new GroupChangeFailedException(e); } catch (IOException e) { Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e); ApplicationDependencies.getJobManager() .add(new RequestGroupV2InfoJob(groupId)); throw e; } } private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange) throws GroupChangeFailedException { try { return groupOperations.decryptChange(signedGroupChange, false).get(); } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) { Log.w(TAG, e); throw new GroupChangeFailedException(e); } } /** * Creates a local group from what we know before joining. *

* Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a * full diff against this group once we learn more about this group as that would create a large * update message. */ private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) { DecryptedGroup.Builder group = DecryptedGroup.newBuilder() .setTitle(joinInfo.getTitle()) .setAvatar(joinInfo.getAvatar()) .setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION); Recipient self = Recipient.self(); ByteString selfUuid = UuidUtil.toByteString(self.requireUuid()); ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey())); if (requestToJoin) { group.addRequestingMembers(DecryptedRequestingMember.newBuilder() .setUuid(selfUuid) .setProfileKey(profileKey)); } else { group.addMembers(DecryptedMember.newBuilder() .setUuid(selfUuid) .setProfileKey(profileKey)); } return group.build(); } private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException { if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) { throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities"); } GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); if (!self.hasProfileKeyCredential()) { throw new MembershipNotSuitableForV2Exception("No profile key credential for self"); } ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get(); GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential) : groupOperations.createGroupJoinDirect(profileKeyCredential); change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get())); return commitJoinChangeWithConflictResolution(currentRevision, change); } private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change) throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException { for (int attempt = 0; attempt < 5; attempt++) { try { GroupChange.Actions changeActions = change.setRevision(currentRevision + 1) .build(); Log.i(TAG, "Trying to join group at V" + changeActions.getRevision()); GroupChange signedGroupChange = commitJoinToServer(changeActions); Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision()); return signedGroupChange; } catch (GroupPatchNotAcceptedException e) { Log.w(TAG, "Patch not accepted", e); try { if (alreadyPendingAdminApproval() || testGroupMembership()) { throw new GroupJoinAlreadyAMemberException(e); } else { throw new GroupChangeFailedException(e); } } catch (VerificationFailedException | InvalidGroupStateException ex) { throw new GroupChangeFailedException(ex); } } catch (ConflictException e) { Log.w(TAG, "Revision conflict", e); currentRevision = getCurrentGroupRevisionFromServer(); } } throw new GroupChangeFailedException("Unable to join group after conflicts"); } private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change) throws GroupChangeFailedException, IOException, GroupLinkNotActiveException { try { return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize)); } catch (NotInGroupException | VerificationFailedException e) { Log.w(TAG, e); throw new GroupChangeFailedException(e); } catch (AuthorizationFailedException e) { Log.w(TAG, e); throw new GroupLinkNotActiveException(e); } } private int getCurrentGroupRevisionFromServer() throws IOException, GroupLinkNotActiveException, GroupChangeFailedException { try { int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision(); Log.i(TAG, "Server now on V" + currentRevision); return currentRevision; } catch (VerificationFailedException ex) { throw new GroupChangeFailedException(ex); } } private boolean alreadyPendingAdminApproval() throws IOException, GroupLinkNotActiveException, GroupChangeFailedException { try { boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval(); if (pendingAdminApproval) { Log.i(TAG, "User is already pending admin approval"); } return pendingAdminApproval; } catch (VerificationFailedException ex) { throw new GroupChangeFailedException(ex); } } private boolean testGroupMembership() throws IOException, VerificationFailedException, InvalidGroupStateException { try { groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); return true; } catch (NotInGroupException ex) { return false; } } @WorkerThread void cancelJoinRequest() throws GroupChangeFailedException, IOException { Set uuids = Collections.singleton(Recipient.self().getUuid().get()); GroupChange signedGroupChange; try { signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids)); } catch (GroupLinkNotActiveException e) { Log.d(TAG, "Unexpected unable to leave group due to group link off"); throw new GroupChangeFailedException(e); } DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); try { DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get(); DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange); groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision())); sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange); } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { throw new GroupChangeFailedException(e); } } private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) { return DecryptedGroup.newBuilder(newGroup) .setRevision(revision) .build(); } private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change) throws GroupChangeFailedException, IOException, GroupLinkNotActiveException { int currentRevision = getCurrentGroupRevisionFromServer(); for (int attempt = 0; attempt < 5; attempt++) { try { GroupChange.Actions changeActions = change.setRevision(currentRevision + 1) .build(); Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision()); GroupChange signedGroupChange = commitJoinToServer(changeActions); Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision()); return signedGroupChange; } catch (GroupPatchNotAcceptedException e) { throw new GroupChangeFailedException(e); } catch (ConflictException e) { Log.w(TAG, "Revision conflict", e); currentRevision = getCurrentGroupRevisionFromServer(); } } throw new GroupChangeFailedException("Unable to cancel group join request after conflicts"); } } private abstract static class LockOwner implements Closeable { final Closeable lock; LockOwner(@NonNull Closeable lock) { this.lock = lock; } @Override public void close() throws IOException { lock.close(); } } private @NonNull RecipientAndThread sendGroupUpdate(@NonNull GroupMasterKey masterKey, @NonNull GroupMutation groupMutation, @Nullable GroupChange signedGroupChange) { GroupId.V2 groupId = GroupId.v2(masterKey); Recipient groupRecipient = Recipient.externalGroup(context, groupId); DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage)); return new RecipientAndThread(groupRecipient, -1); } else { long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); return new RecipientAndThread(groupRecipient, threadId); } } private static @NonNull List getPendingMemberRecipientIds(@NonNull List newPendingMembersList) { return Stream.of(DecryptedGroupUtil.pendingToUuidList(newPendingMembersList)) .map(uuid-> RecipientId.from(uuid,null)) .toList(); } private static @NonNull AccessControl.AccessRequired rightsToAccessControl(@NonNull GroupAccessControl rights) { switch (rights){ case ALL_MEMBERS: return AccessControl.AccessRequired.MEMBER; case ONLY_ADMINS: return AccessControl.AccessRequired.ADMINISTRATOR; case NO_ONE: return AccessControl.AccessRequired.UNSATISFIABLE; default: throw new AssertionError(); } } static class RecipientAndThread { private final Recipient groupRecipient; private final long threadId; RecipientAndThread(@NonNull Recipient groupRecipient, long threadId) { this.groupRecipient = groupRecipient; this.threadId = threadId; } } }