Signal-Android/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java

1023 lines
49 KiB
Java

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<RecipientId> 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<GroupCandidate> 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<RecipientId> 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<GroupCandidate> 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<UuidCiphertext> uuidCipherTexts)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult approveRequests(@NonNull Collection<RecipientId> recipientIds)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Set<UUID> 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<RecipientId> recipientIds)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Set<UUID> 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<DecryptedPendingMember> pendingMembersList = groupRecord.requireV2GroupProperties().getDecryptedGroup().getPendingMembersList();
Optional<DecryptedPendingMember> 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<RecipientId> newAdmins)
throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException
{
Recipient self = Recipient.self();
List<UUID> 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<DecryptedMember> 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<DecryptedMember> 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<RecipientId> 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<GroupDatabase.GroupRecord> 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.
* <p>
* 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<UUID> 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<RecipientId> getPendingMemberRecipientIds(@NonNull List<DecryptedPendingMember> 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;
}
}
}