]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Fix NPR when loading an inactive group
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / GroupHelper.java
index 527fa7ab41ee646181a8bebb00ef944f30b2706a..682bd9961349bb06cc8fe48b50cec6cf196d6774 100644 (file)
 package org.asamk.signal.manager.helper;
 
-import com.google.protobuf.InvalidProtocolBufferException;
-
-import org.asamk.signal.manager.groups.GroupLinkPassword;
+import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.manager.api.GroupId;
+import org.asamk.signal.manager.api.GroupIdV1;
+import org.asamk.signal.manager.api.GroupIdV2;
+import org.asamk.signal.manager.api.GroupInviteLinkUrl;
+import org.asamk.signal.manager.api.GroupLinkState;
+import org.asamk.signal.manager.api.GroupNotFoundException;
+import org.asamk.signal.manager.api.GroupPermission;
+import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
+import org.asamk.signal.manager.api.InactiveGroupLinkException;
+import org.asamk.signal.manager.api.LastGroupAdminException;
+import org.asamk.signal.manager.api.NotAGroupMemberException;
+import org.asamk.signal.manager.api.Pair;
+import org.asamk.signal.manager.api.PendingAdminApprovalException;
+import org.asamk.signal.manager.api.SendGroupMessageResults;
+import org.asamk.signal.manager.api.SendMessageResult;
+import org.asamk.signal.manager.config.ServiceConfig;
 import org.asamk.signal.manager.groups.GroupUtils;
+import org.asamk.signal.manager.internal.SignalDependencies;
+import org.asamk.signal.manager.jobs.SyncStorageJob;
+import org.asamk.signal.manager.storage.SignalAccount;
+import org.asamk.signal.manager.storage.groups.GroupInfo;
+import org.asamk.signal.manager.storage.groups.GroupInfoV1;
 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
-import org.asamk.signal.manager.storage.recipients.Profile;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
+import org.asamk.signal.manager.util.AttachmentUtils;
 import org.asamk.signal.manager.util.IOUtils;
-import org.signal.storageservice.protos.groups.AccessControl;
+import org.asamk.signal.manager.util.Utils;
+import org.signal.libsignal.zkgroup.InvalidInputException;
+import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
+import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
+import org.signal.libsignal.zkgroup.profiles.ProfileKey;
 import org.signal.storageservice.protos.groups.GroupChange;
-import org.signal.storageservice.protos.groups.Member;
+import org.signal.storageservice.protos.groups.GroupChangeResponse;
 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.DecryptedPendingMember;
-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.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.whispersystems.libsignal.util.Pair;
-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.DecryptedGroupChangeLog;
+import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupResponse;
 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
-import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
-import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
-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.SignalServiceAddress;
-import org.whispersystems.signalservice.api.util.UuidUtil;
-
-import java.io.File;
-import java.io.FileInputStream;
+import org.whispersystems.signalservice.api.groupsv2.ReceivedGroupSendEndorsements;
+import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
+import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
+import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
+import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
+import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
+import org.whispersystems.signalservice.api.push.DistributionId;
+import org.whispersystems.signalservice.api.push.ServiceId;
+import org.whispersystems.signalservice.api.push.exceptions.ConflictException;
+
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
-import java.util.UUID;
-import java.util.stream.Collectors;
 
 public class GroupHelper {
 
-    private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
+    private static final Logger logger = LoggerFactory.getLogger(GroupHelper.class);
 
-    private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
+    private final SignalAccount account;
+    private final SignalDependencies dependencies;
+    private final Context context;
 
-    private final ProfileProvider profileProvider;
+    public GroupHelper(final Context context) {
+        this.account = context.getAccount();
+        this.dependencies = context.getDependencies();
+        this.context = context;
+    }
 
-    private final SelfRecipientIdProvider selfRecipientIdProvider;
+    public GroupInfo getGroup(GroupId groupId) {
+        return getGroup(groupId, false);
+    }
 
-    private final GroupsV2Operations groupsV2Operations;
+    public void updateGroupSendEndorsements(GroupId groupId) {
+        getGroup(groupId, true);
+    }
 
-    private final GroupsV2Api groupsV2Api;
+    public List<GroupInfo> getGroups() {
+        final var groups = account.getGroupStore().getGroups();
+        groups.forEach(group -> fillOrUpdateGroup(group, false));
+        return groups;
+    }
 
-    private final GroupAuthorizationProvider groupAuthorizationProvider;
+    public boolean isGroupBlocked(final GroupId groupId) {
+        var group = getGroup(groupId);
+        return group != null && group.isBlocked();
+    }
 
-    private final SignalServiceAddressResolver addressResolver;
+    public void downloadGroupAvatar(GroupIdV1 groupId, SignalServiceAttachment avatar) {
+        try {
+            context.getAvatarStore()
+                    .storeGroupAvatar(groupId,
+                            outputStream -> context.getAttachmentHelper().retrieveAttachment(avatar, outputStream));
+        } catch (IOException e) {
+            logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
+        }
+    }
 
-    public GroupHelper(
-            final ProfileKeyCredentialProvider profileKeyCredentialProvider,
-            final ProfileProvider profileProvider,
-            final SelfRecipientIdProvider selfRecipientIdProvider,
-            final GroupsV2Operations groupsV2Operations,
-            final GroupsV2Api groupsV2Api,
-            final GroupAuthorizationProvider groupAuthorizationProvider,
-            final SignalServiceAddressResolver addressResolver
-    ) {
-        this.profileKeyCredentialProvider = profileKeyCredentialProvider;
-        this.profileProvider = profileProvider;
-        this.selfRecipientIdProvider = selfRecipientIdProvider;
-        this.groupsV2Operations = groupsV2Operations;
-        this.groupsV2Api = groupsV2Api;
-        this.groupAuthorizationProvider = groupAuthorizationProvider;
-        this.addressResolver = addressResolver;
+    public Optional<SignalServiceAttachmentStream> createGroupAvatarAttachment(GroupIdV1 groupId) throws IOException {
+        final var streamDetails = context.getAvatarStore().retrieveGroupAvatar(groupId);
+        if (streamDetails == null) {
+            return Optional.empty();
+        }
+
+        final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
+        return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
     }
 
-    public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
-        try {
-            final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
-                    groupSecretParams);
-            return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
-        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
-            logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
-            return null;
+    public GroupInfoV2 getOrMigrateGroup(
+            final GroupMasterKey groupMasterKey,
+            final int revision,
+            final byte[] signedGroupChange
+    ) {
+        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+
+        final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
+        final var groupInfoV2 = account.getGroupStore().getGroupOrPartialMigrate(groupMasterKey, groupId);
+
+        if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().revision < revision) {
+            DecryptedGroup group = null;
+            if (signedGroupChange != null
+                    && groupInfoV2.getGroup() != null
+                    && groupInfoV2.getGroup().revision + 1 == revision) {
+                final var decryptedGroupChange = context.getGroupV2Helper()
+                        .getDecryptedGroupChange(signedGroupChange, groupMasterKey);
+
+                if (decryptedGroupChange != null) {
+                    storeProfileKeyFromChange(decryptedGroupChange);
+                    group = context.getGroupV2Helper()
+                            .getUpdatedDecryptedGroup(groupInfoV2.getGroup(), decryptedGroupChange);
+                }
+            }
+            if (group == null) {
+                try {
+                    final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
+
+                    if (response != null) {
+                        group = handleDecryptedGroupResponse(groupInfoV2, response);
+                        storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group);
+                    }
+                } catch (NotAGroupMemberException ignored) {
+                }
+            }
+            if (group != null) {
+                storeProfileKeysFromMembers(group);
+                final var avatar = group.avatar;
+                if (!avatar.isEmpty()) {
+                    downloadGroupAvatar(groupId, groupSecretParams, avatar);
+                }
+            }
+            groupInfoV2.setGroup(group);
+            account.getGroupStore().updateGroup(groupInfoV2);
+            context.getJobExecutor().enqueueJob(new SyncStorageJob());
         }
+
+        return groupInfoV2;
     }
 
-    public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
-            GroupMasterKey groupMasterKey, GroupLinkPassword password
-    ) throws IOException, GroupLinkNotActiveException {
-        var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+    private DecryptedGroup handleDecryptedGroupResponse(
+            GroupInfoV2 groupInfoV2,
+            final DecryptedGroupResponse decryptedGroupResponse
+    ) {
+        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
+        ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations()
+                .forGroup(groupSecretParams)
+                .receiveGroupSendEndorsements(account.getAci(),
+                        decryptedGroupResponse.getGroup(),
+                        decryptedGroupResponse.getGroupSendEndorsementsResponse());
+
+        // TODO save group endorsements
 
-        return groupsV2Api.getGroupJoinInfo(groupSecretParams,
-                Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
+        return decryptedGroupResponse.getGroup();
     }
 
-    public Pair<GroupInfoV2, DecryptedGroup> createGroupV2(
-            String name, Set<RecipientId> members, File avatarFile
-    ) throws IOException {
+    private GroupChange handleGroupChangeResponse(
+            final GroupInfoV2 groupInfoV2,
+            final GroupChangeResponse groupChangeResponse
+    ) {
+        ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations()
+                .forGroup(GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()))
+                .receiveGroupSendEndorsements(account.getAci(),
+                        groupInfoV2.getGroup(),
+                        groupChangeResponse.groupSendEndorsementsResponse);
+
+        // TODO save group endorsements
+
+        return groupChangeResponse.groupChange;
+    }
+
+    public Pair<GroupId, SendGroupMessageResults> createGroup(
+            String name,
+            Set<RecipientId> members,
+            String avatarFile
+    ) throws IOException, AttachmentInvalidException {
+        final var selfRecipientId = account.getSelfRecipientId();
+        if (members != null && members.contains(selfRecipientId)) {
+            members = new HashSet<>(members);
+            members.remove(selfRecipientId);
+        }
+
         final var avatarBytes = readAvatarBytes(avatarFile);
-        final var newGroup = buildNewGroupV2(name, members, avatarBytes);
-        if (newGroup == null) {
-            return null;
+        var gv2Pair = context.getGroupV2Helper()
+                .createGroup(name == null ? "" : name, members == null ? Set.of() : members, avatarBytes);
+
+        if (gv2Pair == null) {
+            // Failed to create v2 group, creating v1 group instead
+            var gv1 = new GroupInfoV1(GroupIdV1.createRandom());
+            gv1.setProfileSharingEnabled(true);
+            gv1.addMembers(List.of(selfRecipientId));
+            final var result = updateGroupV1(gv1, name, members, avatarBytes);
+            return new Pair<>(gv1.getGroupId(), result);
+        }
+
+        final var gv2 = gv2Pair.first();
+        final var decryptedGroup = gv2Pair.second();
+
+        gv2.setGroup(handleDecryptedGroupResponse(gv2, decryptedGroup));
+        gv2.setProfileSharingEnabled(true);
+        if (avatarBytes != null) {
+            context.getAvatarStore()
+                    .storeGroupAvatar(gv2.getGroupId(), outputStream -> outputStream.write(avatarBytes));
+        }
+
+        account.getGroupStore().updateGroup(gv2);
+
+        final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null);
+
+        final var result = sendGroupMessage(messageBuilder,
+                gv2.getMembersIncludingPendingWithout(selfRecipientId),
+                gv2.getDistributionId());
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+        return new Pair<>(gv2.getGroupId(), result);
+    }
+
+    public SendGroupMessageResults updateGroup(
+            final GroupId groupId,
+            final String name,
+            final String description,
+            final Set<RecipientId> members,
+            final Set<RecipientId> removeMembers,
+            final Set<RecipientId> admins,
+            final Set<RecipientId> removeAdmins,
+            final Set<RecipientId> banMembers,
+            final Set<RecipientId> unbanMembers,
+            final boolean resetGroupLink,
+            final GroupLinkState groupLinkState,
+            final GroupPermission addMemberPermission,
+            final GroupPermission editDetailsPermission,
+            final String avatarFile,
+            final Integer expirationTimer,
+            final Boolean isAnnouncementGroup
+    ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
+        var group = getGroupForUpdating(groupId);
+        final var avatarBytes = readAvatarBytes(avatarFile);
+
+        SendGroupMessageResults results;
+        switch (group) {
+            case GroupInfoV2 gv2 -> {
+                try {
+                    results = updateGroupV2(gv2,
+                            name,
+                            description,
+                            members,
+                            removeMembers,
+                            admins,
+                            removeAdmins,
+                            banMembers,
+                            unbanMembers,
+                            resetGroupLink,
+                            groupLinkState,
+                            addMemberPermission,
+                            editDetailsPermission,
+                            avatarBytes,
+                            expirationTimer,
+                            isAnnouncementGroup);
+                } catch (ConflictException e) {
+                    // Detected conflicting update, refreshing group and trying again
+                    group = getGroup(groupId, true);
+                    results = updateGroupV2((GroupInfoV2) group,
+                            name,
+                            description,
+                            members,
+                            removeMembers,
+                            admins,
+                            removeAdmins,
+                            banMembers,
+                            unbanMembers,
+                            resetGroupLink,
+                            groupLinkState,
+                            addMemberPermission,
+                            editDetailsPermission,
+                            avatarBytes,
+                            expirationTimer,
+                            isAnnouncementGroup);
+                }
+            }
+
+            case GroupInfoV1 gv1 -> {
+                results = updateGroupV1(gv1, name, members, avatarBytes);
+                if (expirationTimer != null) {
+                    setExpirationTimer(gv1, expirationTimer);
+                }
+            }
         }
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+        return results;
+    }
+
+    public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
+        var group = getGroupForUpdating(groupId);
 
-        final var groupSecretParams = newGroup.getGroupSecretParams();
+        if (group instanceof GroupInfoV2 groupInfoV2) {
+            Pair<DecryptedGroup, GroupChangeResponse> groupChangePair;
+            try {
+                groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
+            } catch (ConflictException e) {
+                // Detected conflicting update, refreshing group and trying again
+                groupInfoV2 = (GroupInfoV2) getGroup(groupId, true);
+                groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
+            }
+            if (groupChangePair != null) {
+                sendUpdateGroupV2Message(groupInfoV2,
+                        groupChangePair.first(),
+                        handleGroupChangeResponse(groupInfoV2, groupChangePair.second()));
+            }
+        }
+    }
 
-        final GroupsV2AuthorizationString groupAuthForToday;
-        final DecryptedGroup decryptedGroup;
+    public Pair<GroupId, SendGroupMessageResults> joinGroup(
+            GroupInviteLinkUrl inviteLinkUrl
+    ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
+        final DecryptedGroupJoinInfo groupJoinInfo;
         try {
-            groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
-            groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
-            decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
-        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
-            logger.warn("Failed to create V2 group: {}", e.getMessage());
-            return null;
+            groupJoinInfo = context.getGroupV2Helper()
+                    .getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword());
+        } catch (GroupLinkNotActiveException e) {
+            throw new InactiveGroupLinkException("Group link inactive (reason: " + e.getReason() + ")", e);
         }
-        if (decryptedGroup == null) {
-            logger.warn("Failed to create V2 group, unknown error!");
-            return null;
+        if (groupJoinInfo.pendingAdminApproval) {
+            throw new PendingAdminApprovalException("You have already requested to join the group.");
+        }
+        final var changeResponse = context.getGroupV2Helper()
+                .joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo);
+        final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
+                groupJoinInfo.revision + 1,
+                changeResponse.groupChange == null ? null : changeResponse.groupChange.encode());
+        final var groupChange = handleGroupChangeResponse(group, changeResponse);
+
+        if (group.getGroup() == null) {
+            // Only requested member, can't send update to group members
+            return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of()));
         }
 
-        final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
-        final var masterKey = groupSecretParams.getMasterKey();
-        var g = new GroupInfoV2(groupId, masterKey);
+        final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
 
-        return new Pair<>(g, decryptedGroup);
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+        return new Pair<>(group.getGroupId(), result);
     }
 
-    private byte[] readAvatarBytes(final File avatarFile) throws IOException {
-        final byte[] avatarBytes;
-        try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
-            avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
+    public SendGroupMessageResults quitGroup(
+            final GroupId groupId,
+            final Set<RecipientId> newAdmins
+    ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
+        var group = getGroupForUpdating(groupId);
+        if (group instanceof GroupInfoV1) {
+            return quitGroupV1((GroupInfoV1) group);
+        }
+
+        try {
+            return quitGroupV2((GroupInfoV2) group, newAdmins);
+        } catch (ConflictException e) {
+            // Detected conflicting update, refreshing group and trying again
+            group = getGroup(groupId, true);
+            return quitGroupV2((GroupInfoV2) group, newAdmins);
         }
-        return avatarBytes;
     }
 
-    private GroupsV2Operations.NewGroup buildNewGroupV2(
-            String name, Set<RecipientId> members, byte[] avatar
-    ) {
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientIdProvider.getSelfRecipientId());
-        if (profileKeyCredential == null) {
-            logger.warn("Cannot create a V2 group as self does not have a versioned profile");
-            return null;
+    public void deleteGroup(GroupId groupId) throws IOException {
+        account.getGroupStore().deleteGroup(groupId);
+        context.getAvatarStore().deleteGroupAvatar(groupId);
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+    }
+
+    public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
+        var group = getGroup(groupId);
+        if (group == null) {
+            throw new GroupNotFoundException(groupId);
         }
 
-        if (!areMembersValid(members)) return null;
+        group.setBlocked(blocked);
+        account.getGroupStore().updateGroup(group);
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+    }
+
+    public SendGroupMessageResults sendGroupInfoRequest(GroupIdV1 groupId, RecipientId recipientId) throws IOException {
+        var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
 
-        var self = new GroupCandidate(addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .orNull(), Optional.fromNullable(profileKeyCredential));
-        var candidates = members.stream()
-                .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
-                        Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
-                .collect(Collectors.toSet());
+        var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
 
-        final var groupSecretParams = GroupSecretParams.generate();
-        return groupsV2Operations.createNewGroup(groupSecretParams,
-                name,
-                Optional.fromNullable(avatar),
-                self,
-                candidates,
-                Member.Role.DEFAULT,
-                0);
+        // Send group info request message to the recipient who sent us a message with this groupId
+        return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
     }
 
-    private boolean areMembersValid(final Set<RecipientId> members) {
-        final var noUuidCapability = members.stream()
-                .map(addressResolver::resolveSignalServiceAddress)
-                .filter(address -> !address.getUuid().isPresent())
-                .map(SignalServiceAddress::getLegacyIdentifier)
-                .collect(Collectors.toSet());
-        if (noUuidCapability.size() > 0) {
-            logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
-                    String.join(", ", noUuidCapability));
-            return false;
+    public SendGroupMessageResults sendGroupInfoMessage(
+            GroupIdV1 groupId,
+            RecipientId recipientId
+    ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
+        GroupInfoV1 g;
+        var group = getGroupForUpdating(groupId);
+        if (!(group instanceof GroupInfoV1)) {
+            throw new IOException("Received an invalid group request for a v2 group!");
         }
+        g = (GroupInfoV1) group;
 
-        final var noGv2Capability = members.stream()
-                .map(profileProvider::getProfile)
-                .filter(profile -> profile != null && !profile.getCapabilities().contains(Profile.Capability.gv2))
-                .collect(Collectors.toSet());
-        if (noGv2Capability.size() > 0) {
-            logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
-                    noGv2Capability.stream().map(Profile::getDisplayName).collect(Collectors.joining(", ")));
-            return false;
+        if (!g.isMember(recipientId)) {
+            throw new NotAGroupMemberException(groupId, g.name);
         }
 
-        return true;
+        var messageBuilder = getGroupUpdateMessageBuilder(g);
+
+        // Send group message only to the recipient who requested it
+        return sendGroupMessage(messageBuilder, Set.of(recipientId), null);
     }
 
-    public Pair<DecryptedGroup, GroupChange> updateGroupV2(
-            GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
-    ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+    private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
+        final var group = account.getGroupStore().getGroup(groupId);
+        fillOrUpdateGroup(group, forceUpdate);
+        return group;
+    }
 
-        var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
+    private void fillOrUpdateGroup(final GroupInfo group, final boolean forceUpdate) {
+        if (!(group instanceof GroupInfoV2 groupInfoV2)) {
+            return;
+        }
 
-        if (description != null) {
-            change.setModifyDescription(groupOperations.createModifyGroupDescription(description));
+        if (!forceUpdate && (groupInfoV2.isPermissionDenied() || groupInfoV2.getGroup() != null)) {
+            return;
         }
 
-        if (avatarFile != null) {
-            final var avatarBytes = readAvatarBytes(avatarFile);
-            var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
-                    groupSecretParams,
-                    groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
-            change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
+        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
+        DecryptedGroup decryptedGroup;
+        try {
+            final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
+            if (response == null) {
+                return;
+            }
+            decryptedGroup = handleDecryptedGroupResponse(groupInfoV2, response);
+        } catch (NotAGroupMemberException e) {
+            groupInfoV2.setPermissionDenied(true);
+            account.getGroupStore().updateGroup(group);
+            return;
         }
 
-        final var uuid = addressResolver.resolveSignalServiceAddress(this.selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid();
-        if (uuid.isPresent()) {
-            change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
+        try {
+            storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, decryptedGroup);
+        } catch (NotAGroupMemberException ignored) {
         }
+        storeProfileKeysFromMembers(decryptedGroup);
+        final var avatar = decryptedGroup.avatar;
+        if (!avatar.isEmpty()) {
+            downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
+        }
+        groupInfoV2.setGroup(decryptedGroup);
+        account.getGroupStore().updateGroup(group);
+    }
 
-        return commitChange(groupInfoV2, change);
+    private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
+        try {
+            context.getAvatarStore()
+                    .storeGroupAvatar(groupId,
+                            outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream));
+        } catch (IOException e) {
+            logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage());
+        }
     }
 
-    public Pair<DecryptedGroup, GroupChange> updateGroupV2(
-            GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
+    private void retrieveGroupV2Avatar(
+            GroupSecretParams groupSecretParams,
+            String cdnKey,
+            OutputStream outputStream
     ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+        var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
 
-        if (!areMembersValid(newMembers)) {
-            throw new IOException("Failed to update group");
-        }
+        var tmpFile = IOUtils.createTempFile();
+        try (InputStream input = dependencies.getMessageReceiver()
+                .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
+            var encryptedData = IOUtils.readFully(input);
 
-        var candidates = newMembers.stream()
-                .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
-                        Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
-                .collect(Collectors.toSet());
+            var decryptedData = groupOperations.decryptAvatar(encryptedData);
+            outputStream.write(decryptedData);
+        } finally {
+            try {
+                Files.delete(tmpFile.toPath());
+            } catch (IOException e) {
+                logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}",
+                        tmpFile,
+                        e.getMessage());
+            }
+        }
+    }
 
-        final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .get();
-        final var change = groupOperations.createModifyGroupMembershipChange(candidates, uuid);
+    private void storeProfileKeysFromMembers(final DecryptedGroup group) {
+        for (var member : group.members) {
+            final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
+            final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
+            final var profileStore = account.getProfileStore();
+            if (profileStore.getProfileKey(recipientId) != null) {
+                // We already have a profile key, not updating it from a non-authoritative source
+                continue;
+            }
+            try {
+                profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
+            } catch (InvalidInputException ignored) {
+            }
+        }
+    }
 
-        change.setSourceUuid(UuidUtil.toByteString(uuid));
+    private void storeProfileKeyFromChange(final DecryptedGroupChange decryptedGroupChange) {
+        final var profileKeyFromChange = context.getGroupV2Helper()
+                .getAuthoritativeProfileKeyFromChange(decryptedGroupChange);
 
-        return commitChange(groupInfoV2, change);
+        if (profileKeyFromChange != null) {
+            final var serviceId = profileKeyFromChange.first();
+            final var profileKey = profileKeyFromChange.second();
+            final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
+            account.getProfileStore().storeProfileKey(recipientId, profileKey);
+        }
     }
 
-    public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
-        var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
-        final var selfUuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .get();
-        var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
+    private void storeProfileKeysFromHistory(
+            final GroupSecretParams groupSecretParams,
+            final GroupInfoV2 localGroup,
+            final DecryptedGroup newDecryptedGroup
+    ) throws NotAGroupMemberException {
+        final var revisionWeWereAdded = context.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup);
+        final var localRevision = localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
+        final var sendEndorsementsExpirationMs = 0L;// TODO store expiration localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision;
+        var fromRevision = Math.max(revisionWeWereAdded, localRevision);
+        final var newProfileKeys = new HashMap<RecipientId, ProfileKey>();
+        while (true) {
+            final var page = context.getGroupV2Helper()
+                    .getDecryptedGroupHistoryPage(groupSecretParams, fromRevision, sendEndorsementsExpirationMs);
+            if (page == null) {
+                break;
+            }
+            page.getChangeLogs()
+                    .stream()
+                    .map(DecryptedGroupChangeLog::getChange)
+                    .filter(Objects::nonNull)
+                    .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
+                    .filter(Objects::nonNull)
+                    .forEach(p -> {
+                        final var serviceId = p.first();
+                        final var profileKey = p.second();
+                        final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
+                        newProfileKeys.put(recipientId, profileKey);
+                    });
+            if (!page.getPagingData().getHasMorePages()) {
+                break;
+            }
+            fromRevision = page.getPagingData().getNextPageRevision();
+        }
+
+        newProfileKeys.entrySet()
+                .stream()
+                .filter(entry -> account.getProfileStore().getProfileKey(entry.getKey()) == null)
+                .forEach(entry -> account.getProfileStore().storeProfileKey(entry.getKey(), entry.getValue()));
+    }
 
-        if (selfPendingMember.isPresent()) {
-            return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
-        } else {
-            return ejectMembers(groupInfoV2, Set.of(selfUuid));
+    private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
+        var g = getGroup(groupId);
+        if (g == null) {
+            throw new GroupNotFoundException(groupId);
+        }
+        if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) {
+            throw new NotAGroupMemberException(groupId, g.getTitle());
+        }
+        if (groupId instanceof GroupIdV2) {
+            // Refresh group before updating
+            return getGroup(groupId, true);
         }
+        return g;
     }
 
-    public Pair<DecryptedGroup, GroupChange> removeMembers(
-            GroupInfoV2 groupInfoV2, Set<RecipientId> members
-    ) throws IOException {
-        final var memberUuids = members.stream()
-                .map(addressResolver::resolveSignalServiceAddress)
-                .map(SignalServiceAddress::getUuid)
-                .filter(Optional::isPresent)
-                .map(Optional::get)
-                .collect(Collectors.toSet());
-        return ejectMembers(groupInfoV2, memberUuids);
+    private SendGroupMessageResults updateGroupV1(
+            final GroupInfoV1 gv1,
+            final String name,
+            final Set<RecipientId> members,
+            final byte[] avatarFile
+    ) throws IOException, AttachmentInvalidException {
+        updateGroupV1Details(gv1, name, members, avatarFile);
+
+        account.getGroupStore().updateGroup(gv1);
+
+        var messageBuilder = getGroupUpdateMessageBuilder(gv1);
+        return sendGroupMessage(messageBuilder,
+                gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
+                gv1.getDistributionId());
     }
 
-    public Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
-            GroupInfoV2 groupInfoV2, Set<RecipientId> members
+    private void updateGroupV1Details(
+            final GroupInfoV1 g,
+            final String name,
+            final Collection<RecipientId> members,
+            final byte[] avatarFile
     ) throws IOException {
-        var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
-        final var memberUuids = members.stream()
-                .map(addressResolver::resolveSignalServiceAddress)
-                .map(SignalServiceAddress::getUuid)
-                .filter(Optional::isPresent)
-                .map(Optional::get)
-                .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
-                .filter(Optional::isPresent)
-                .map(Optional::get)
-                .collect(Collectors.toSet());
-        return revokeInvites(groupInfoV2, memberUuids);
-    }
-
-    public GroupChange joinGroup(
-            GroupMasterKey groupMasterKey,
-            GroupLinkPassword groupLinkPassword,
-            DecryptedGroupJoinInfo decryptedGroupJoinInfo
-    ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+        if (name != null) {
+            g.name = name;
+        }
 
-        final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
-        if (profileKeyCredential == null) {
-            throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
+        if (members != null) {
+            g.addMembers(members);
         }
 
-        var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
-        var change = requestToJoin
-                ? groupOperations.createGroupJoinRequest(profileKeyCredential)
-                : groupOperations.createGroupJoinDirect(profileKeyCredential);
+        if (avatarFile != null) {
+            context.getAvatarStore().storeGroupAvatar(g.getGroupId(), outputStream -> outputStream.write(avatarFile));
+        }
+    }
 
-        change.setSourceUuid(UuidUtil.toByteString(addressResolver.resolveSignalServiceAddress(selfRecipientId)
-                .getUuid()
-                .get()));
+    /**
+     * Change the expiration timer for a group
+     */
+    private void setExpirationTimer(
+            GroupInfoV1 groupInfoV1,
+            int messageExpirationTimer
+    ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
+        groupInfoV1.messageExpirationTime = messageExpirationTimer;
+        account.getGroupStore().updateGroup(groupInfoV1);
+        sendExpirationTimerUpdate(groupInfoV1.getGroupId());
+    }
 
-        return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
+    private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
+        final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
+        context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty());
     }
 
-    public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+    private SendGroupMessageResults updateGroupV2(
+            final GroupInfoV2 group,
+            final String name,
+            final String description,
+            final Set<RecipientId> members,
+            final Set<RecipientId> removeMembers,
+            final Set<RecipientId> admins,
+            final Set<RecipientId> removeAdmins,
+            final Set<RecipientId> banMembers,
+            final Set<RecipientId> unbanMembers,
+            final boolean resetGroupLink,
+            final GroupLinkState groupLinkState,
+            final GroupPermission addMemberPermission,
+            final GroupPermission editDetailsPermission,
+            final byte[] avatarFile,
+            final Integer expirationTimer,
+            final Boolean isAnnouncementGroup
+    ) throws IOException {
+        SendGroupMessageResults result = null;
+        final var groupV2Helper = context.getGroupV2Helper();
+        if (group.isPendingMember(account.getSelfRecipientId())) {
+            var groupGroupChangePair = groupV2Helper.acceptInvite(group);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
 
-        final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
-        if (profileKeyCredential == null) {
-            throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
+        if (members != null) {
+            final var requestingMembers = new HashSet<>(members);
+            requestingMembers.retainAll(group.getRequestingMembers());
+            if (!requestingMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.approveJoinRequestMembers(group, requestingMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
+            final var newMembers = new HashSet<>(members);
+            newMembers.removeAll(group.getMembers());
+            newMembers.removeAll(group.getRequestingMembers());
+            if (!newMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
         }
 
-        final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
+        if (removeMembers != null) {
+            var existingRemoveMembers = new HashSet<>(removeMembers);
+            if (banMembers != null) {
+                existingRemoveMembers.addAll(banMembers);
+            }
+            existingRemoveMembers.retainAll(group.getMembers());
+            if (members != null) {
+                existingRemoveMembers.removeAll(members);
+            }
+            existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
+            if (!existingRemoveMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
 
-        final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientId).getUuid();
-        if (uuid.isPresent()) {
-            change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
+            var pendingRemoveMembers = new HashSet<>(removeMembers);
+            pendingRemoveMembers.retainAll(group.getPendingMembers());
+            if (!pendingRemoveMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
+            var requestingRemoveMembers = new HashSet<>(removeMembers);
+            requestingRemoveMembers.retainAll(group.getRequestingMembers());
+            if (!requestingRemoveMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
         }
 
-        return commitChange(groupInfoV2, change);
-    }
+        if (admins != null) {
+            final var newAdmins = new HashSet<>(admins);
+            newAdmins.retainAll(group.getMembers());
+            newAdmins.removeAll(group.getAdminMembers());
+            if (!newAdmins.isEmpty()) {
+                for (var admin : newAdmins) {
+                    var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
+                    result = sendUpdateGroupV2Message(group,
+                            groupGroupChangePair.first(),
+                            handleGroupChangeResponse(group, groupGroupChangePair.second()));
+                }
+            }
+        }
 
-    private Pair<DecryptedGroup, GroupChange> revokeInvites(
-            GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
-    ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
-        final var uuidCipherTexts = pendingMembers.stream().map(member -> {
-            try {
-                return new UuidCiphertext(member.getUuidCipherText().toByteArray());
-            } catch (InvalidInputException e) {
-                throw new AssertionError(e);
+        if (removeAdmins != null) {
+            final var existingRemoveAdmins = new HashSet<>(removeAdmins);
+            existingRemoveAdmins.retainAll(group.getAdminMembers());
+            if (!existingRemoveAdmins.isEmpty()) {
+                for (var admin : existingRemoveAdmins) {
+                    var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
+                    result = sendUpdateGroupV2Message(group,
+                            groupGroupChangePair.first(),
+                            handleGroupChangeResponse(group, groupGroupChangePair.second()));
+                }
             }
-        }).collect(Collectors.toSet());
-        return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
+        }
+
+        if (banMembers != null) {
+            final var newlyBannedMembers = new HashSet<>(banMembers);
+            newlyBannedMembers.removeAll(group.getBannedMembers());
+            if (!newlyBannedMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
+        }
+
+        if (unbanMembers != null) {
+            var existingUnbanMembers = new HashSet<>(unbanMembers);
+            existingUnbanMembers.retainAll(group.getBannedMembers());
+            if (!existingUnbanMembers.isEmpty()) {
+                var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
+            }
+        }
+
+        if (resetGroupLink) {
+            var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (groupLinkState != null) {
+            var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (addMemberPermission != null) {
+            var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (editDetailsPermission != null) {
+            var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (expirationTimer != null) {
+            var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (isAnnouncementGroup != null) {
+            var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        if (name != null || description != null || avatarFile != null) {
+            var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
+            if (avatarFile != null) {
+                context.getAvatarStore()
+                        .storeGroupAvatar(group.getGroupId(), outputStream -> outputStream.write(avatarFile));
+            }
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
+        }
+
+        return result;
     }
 
-    private Pair<DecryptedGroup, GroupChange> ejectMembers(
-            GroupInfoV2 groupInfoV2, Set<UUID> uuids
-    ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
-        return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
+    private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException {
+        var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
+                .withId(groupInfoV1.getGroupId().serialize())
+                .build();
+
+        var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group);
+        groupInfoV1.removeMember(account.getSelfRecipientId());
+        account.getGroupStore().updateGroup(groupInfoV1);
+        return sendGroupMessage(messageBuilder,
+                groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
+                groupInfoV1.getDistributionId());
     }
 
-    private Pair<DecryptedGroup, GroupChange> commitChange(
-            GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
-    ) throws IOException {
-        final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
-        final var previousGroupState = groupInfoV2.getGroup();
-        final var nextRevision = previousGroupState.getRevision() + 1;
-        final var changeActions = change.setRevision(nextRevision).build();
-        final DecryptedGroupChange decryptedChange;
-        final DecryptedGroup decryptedGroupState;
+    private SendGroupMessageResults quitGroupV2(
+            final GroupInfoV2 groupInfoV2,
+            final Set<RecipientId> newAdmins
+    ) throws LastGroupAdminException, IOException {
+        final var currentAdmins = groupInfoV2.getAdminMembers();
+        newAdmins.removeAll(currentAdmins);
+        newAdmins.retainAll(groupInfoV2.getMembers());
+        if (currentAdmins.contains(account.getSelfRecipientId())
+                && currentAdmins.size() == 1
+                && groupInfoV2.getMembers().size() > 1
+                && newAdmins.isEmpty()) {
+            // Last admin can't leave the group, unless she's also the last member
+            throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
+        }
+        final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
+        groupInfoV2.setGroup(groupGroupChangePair.first());
+        account.getGroupStore().updateGroup(groupInfoV2);
+
+        var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2,
+                handleGroupChangeResponse(groupInfoV2, groupGroupChangePair.second()).encode());
+        return sendGroupMessage(messageBuilder,
+                groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
+                groupInfoV2.getDistributionId());
+    }
+
+    private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
+        var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
+                .withId(g.getGroupId().serialize())
+                .withName(g.name)
+                .withMembers(g.getMembers()
+                        .stream()
+                        .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+                        .toList());
 
         try {
-            decryptedChange = groupOperations.decryptChange(changeActions,
-                    addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                            .getUuid()
-                            .get());
-            decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
-        } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
-            throw new IOException(e);
+            final var attachment = createGroupAvatarAttachment(g.getGroupId());
+            attachment.ifPresent(group::withAvatar);
+        } catch (IOException e) {
+            throw new AttachmentInvalidException(g.getGroupId().toBase64(), e);
         }
 
-        var signedGroupChange = groupsV2Api.patchGroup(changeActions,
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
-                Optional.absent());
+        return SignalServiceDataMessage.newBuilder()
+                .asGroupMessage(group.build())
+                .withExpiration(g.getMessageExpirationTimer());
+    }
 
-        return new Pair<>(decryptedGroupState, signedGroupChange);
+    private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
+        var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
+                .withRevision(g.getGroup().revision)
+                .withSignedGroupChange(signedGroupChange);
+        return SignalServiceDataMessage.newBuilder()
+                .asGroupMessage(group.build())
+                .withExpiration(g.getMessageExpirationTimer());
     }
 
-    private GroupChange commitChange(
-            GroupSecretParams groupSecretParams,
-            int currentRevision,
-            GroupChange.Actions.Builder change,
-            GroupLinkPassword password
+    private SendGroupMessageResults sendUpdateGroupV2Message(
+            GroupInfoV2 group,
+            DecryptedGroup newDecryptedGroup,
+            GroupChange groupChange
     ) throws IOException {
-        final var nextRevision = currentRevision + 1;
-        final var changeActions = change.setRevision(nextRevision).build();
+        final var selfRecipientId = account.getSelfRecipientId();
+        final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
+        group.setGroup(newDecryptedGroup);
+        members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
+        account.getGroupStore().updateGroup(group);
+
+        final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode());
+        return sendGroupMessage(messageBuilder, members, group.getDistributionId());
+    }
 
-        return groupsV2Api.patchGroup(changeActions,
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
-                Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
+    private SendGroupMessageResults sendGroupMessage(
+            final SignalServiceDataMessage.Builder messageBuilder,
+            final Set<RecipientId> members,
+            final DistributionId distributionId
+    ) throws IOException {
+        final var timestamp = System.currentTimeMillis();
+        messageBuilder.withTimestamp(timestamp);
+        final var results = context.getSendHelper().sendGroupMessage(messageBuilder.build(), members, distributionId);
+        return new SendGroupMessageResults(timestamp,
+                results.stream()
+                        .map(sendMessageResult -> SendMessageResult.from(sendMessageResult,
+                                account.getRecipientResolver(),
+                                account.getRecipientAddressResolver()))
+                        .toList());
     }
 
-    public DecryptedGroup getUpdatedDecryptedGroup(
-            DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
-    ) {
-        try {
-            final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
-            if (decryptedGroupChange == null) {
-                return null;
-            }
-            return DecryptedGroupUtil.apply(group, decryptedGroupChange);
-        } catch (NotAbleToApplyGroupV2ChangeException e) {
+    private byte[] readAvatarBytes(final String avatarFile) throws IOException {
+        if (avatarFile == null) {
             return null;
         }
-    }
-
-    private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
-        if (signedGroupChange != null) {
-            var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
-
-            try {
-                return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
-            } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
-                return null;
-            }
+        try (final var avatar = Utils.createStreamDetails(avatarFile).first()) {
+            return IOUtils.readFully(avatar.getStream());
         }
-
-        return null;
     }
 }