]> 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 f4e9232085194acf8efd5e98c1e0d803eea0abc4..682bd9961349bb06cc8fe48b50cec6cf196d6774 100644 (file)
@@ -1,23 +1,25 @@
 package org.asamk.signal.manager.helper;
 
-import org.asamk.signal.manager.SignalDependencies;
 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.GroupId;
-import org.asamk.signal.manager.groups.GroupIdV1;
-import org.asamk.signal.manager.groups.GroupIdV2;
-import org.asamk.signal.manager.groups.GroupInviteLinkUrl;
-import org.asamk.signal.manager.groups.GroupLinkState;
-import org.asamk.signal.manager.groups.GroupNotFoundException;
-import org.asamk.signal.manager.groups.GroupPermission;
-import org.asamk.signal.manager.groups.GroupSendingNotAllowedException;
 import org.asamk.signal.manager.groups.GroupUtils;
-import org.asamk.signal.manager.groups.LastGroupAdminException;
-import org.asamk.signal.manager.groups.NotAGroupMemberException;
+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;
@@ -25,18 +27,22 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV2;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
 import org.asamk.signal.manager.util.AttachmentUtils;
 import org.asamk.signal.manager.util.IOUtils;
+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.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.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry;
+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.ReceivedGroupSendEndorsements;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
@@ -46,7 +52,6 @@ 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.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -61,7 +66,7 @@ import java.util.Set;
 
 public class GroupHelper {
 
-    private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
+    private static final Logger logger = LoggerFactory.getLogger(GroupHelper.class);
 
     private final SignalAccount account;
     private final SignalDependencies dependencies;
@@ -77,6 +82,16 @@ public class GroupHelper {
         return getGroup(groupId, false);
     }
 
+    public void updateGroupSendEndorsements(GroupId groupId) {
+        getGroup(groupId, true);
+    }
+
+    public List<GroupInfo> getGroups() {
+        final var groups = account.getGroupStore().getGroups();
+        groups.forEach(group -> fillOrUpdateGroup(group, false));
+        return groups;
+    }
+
     public boolean isGroupBlocked(final GroupId groupId) {
         var group = getGroup(groupId);
         return group != null && group.isBlocked();
@@ -98,35 +113,25 @@ public class GroupHelper {
             return Optional.empty();
         }
 
-        return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.empty()));
+        final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
+        return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
     }
 
     public GroupInfoV2 getOrMigrateGroup(
-            final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
+            final GroupMasterKey groupMasterKey,
+            final int revision,
+            final byte[] signedGroupChange
     ) {
         final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
 
-        var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
-        var groupInfo = getGroup(groupId);
-        final GroupInfoV2 groupInfoV2;
-        if (groupInfo instanceof GroupInfoV1) {
-            // Received a v2 group message for a v1 group, we need to locally migrate the group
-            account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId());
-            groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
-            logger.info("Locally migrated group {} to group v2, id: {}",
-                    groupInfo.getGroupId().toBase64(),
-                    groupInfoV2.getGroupId().toBase64());
-        } else if (groupInfo instanceof GroupInfoV2) {
-            groupInfoV2 = (GroupInfoV2) groupInfo;
-        } else {
-            groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
-        }
-
-        if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) {
+        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().getRevision() + 1 == revision) {
+                    && groupInfoV2.getGroup().revision + 1 == revision) {
                 final var decryptedGroupChange = context.getGroupV2Helper()
                         .getDecryptedGroupChange(signedGroupChange, groupMasterKey);
 
@@ -138,9 +143,10 @@ public class GroupHelper {
             }
             if (group == null) {
                 try {
-                    group = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
+                    final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
 
-                    if (group != null) {
+                    if (response != null) {
+                        group = handleDecryptedGroupResponse(groupInfoV2, response);
                         storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group);
                     }
                 } catch (NotAGroupMemberException ignored) {
@@ -148,20 +154,54 @@ public class GroupHelper {
             }
             if (group != null) {
                 storeProfileKeysFromMembers(group);
-                final var avatar = group.getAvatar();
-                if (avatar != null && !avatar.isEmpty()) {
+                final var avatar = group.avatar;
+                if (!avatar.isEmpty()) {
                     downloadGroupAvatar(groupId, groupSecretParams, avatar);
                 }
             }
-            groupInfoV2.setGroup(group, account.getRecipientResolver());
+            groupInfoV2.setGroup(group);
             account.getGroupStore().updateGroup(groupInfoV2);
+            context.getJobExecutor().enqueueJob(new SyncStorageJob());
         }
 
         return groupInfoV2;
     }
 
+    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 decryptedGroupResponse.getGroup();
+    }
+
+    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, File avatarFile
+            String name,
+            Set<RecipientId> members,
+            String avatarFile
     ) throws IOException, AttachmentInvalidException {
         final var selfRecipientId = account.getSelfRecipientId();
         if (members != null && members.contains(selfRecipientId)) {
@@ -169,25 +209,27 @@ public class GroupHelper {
             members.remove(selfRecipientId);
         }
 
+        final var avatarBytes = readAvatarBytes(avatarFile);
         var gv2Pair = context.getGroupV2Helper()
-                .createGroup(name == null ? "" : name, members == null ? Set.of() : members, avatarFile);
+                .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, avatarFile);
+            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(decryptedGroup, account.getRecipientResolver());
-        if (avatarFile != null) {
+        gv2.setGroup(handleDecryptedGroupResponse(gv2, decryptedGroup));
+        gv2.setProfileSharingEnabled(true);
+        if (avatarBytes != null) {
             context.getAvatarStore()
-                    .storeGroupAvatar(gv2.getGroupId(),
-                            outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
+                    .storeGroupAvatar(gv2.getGroupId(), outputStream -> outputStream.write(avatarBytes));
         }
 
         account.getGroupStore().updateGroup(gv2);
@@ -197,6 +239,7 @@ public class GroupHelper {
         final var result = sendGroupMessage(messageBuilder,
                 gv2.getMembersIncludingPendingWithout(selfRecipientId),
                 gv2.getDistributionId());
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
         return new Pair<>(gv2.getGroupId(), result);
     }
 
@@ -214,65 +257,71 @@ public class GroupHelper {
             final GroupLinkState groupLinkState,
             final GroupPermission addMemberPermission,
             final GroupPermission editDetailsPermission,
-            final File avatarFile,
+            final String avatarFile,
             final Integer expirationTimer,
             final Boolean isAnnouncementGroup
     ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
         var group = getGroupForUpdating(groupId);
+        final var avatarBytes = readAvatarBytes(avatarFile);
 
-        if (group instanceof GroupInfoV2) {
-            try {
-                return updateGroupV2((GroupInfoV2) group,
-                        name,
-                        description,
-                        members,
-                        removeMembers,
-                        admins,
-                        removeAdmins,
-                        banMembers,
-                        unbanMembers,
-                        resetGroupLink,
-                        groupLinkState,
-                        addMemberPermission,
-                        editDetailsPermission,
-                        avatarFile,
-                        expirationTimer,
-                        isAnnouncementGroup);
-            } catch (ConflictException e) {
-                // Detected conflicting update, refreshing group and trying again
-                group = getGroup(groupId, true);
-                return updateGroupV2((GroupInfoV2) group,
-                        name,
-                        description,
-                        members,
-                        removeMembers,
-                        admins,
-                        removeAdmins,
-                        banMembers,
-                        unbanMembers,
-                        resetGroupLink,
-                        groupLinkState,
-                        addMemberPermission,
-                        editDetailsPermission,
-                        avatarFile,
-                        expirationTimer,
-                        isAnnouncementGroup);
+        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);
+                }
             }
-        }
 
-        final var gv1 = (GroupInfoV1) group;
-        final var result = updateGroupV1(gv1, name, members, avatarFile);
-        if (expirationTimer != null) {
-            setExpirationTimer(gv1, expirationTimer);
+            case GroupInfoV1 gv1 -> {
+                results = updateGroupV1(gv1, name, members, avatarBytes);
+                if (expirationTimer != null) {
+                    setExpirationTimer(gv1, expirationTimer);
+                }
+            }
         }
-        return result;
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
+        return results;
     }
 
     public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
         var group = getGroupForUpdating(groupId);
 
         if (group instanceof GroupInfoV2 groupInfoV2) {
-            Pair<DecryptedGroup, GroupChange> groupChangePair;
+            Pair<DecryptedGroup, GroupChangeResponse> groupChangePair;
             try {
                 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
             } catch (ConflictException e) {
@@ -281,14 +330,16 @@ public class GroupHelper {
                 groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2);
             }
             if (groupChangePair != null) {
-                sendUpdateGroupV2Message(groupInfoV2, groupChangePair.first(), groupChangePair.second());
+                sendUpdateGroupV2Message(groupInfoV2,
+                        groupChangePair.first(),
+                        handleGroupChangeResponse(groupInfoV2, groupChangePair.second()));
             }
         }
     }
 
     public Pair<GroupId, SendGroupMessageResults> joinGroup(
             GroupInviteLinkUrl inviteLinkUrl
-    ) throws IOException, InactiveGroupLinkException {
+    ) throws IOException, InactiveGroupLinkException, PendingAdminApprovalException {
         final DecryptedGroupJoinInfo groupJoinInfo;
         try {
             groupJoinInfo = context.getGroupV2Helper()
@@ -296,11 +347,15 @@ public class GroupHelper {
         } catch (GroupLinkNotActiveException e) {
             throw new InactiveGroupLinkException("Group link inactive (reason: " + e.getReason() + ")", e);
         }
-        final var groupChange = context.getGroupV2Helper()
+        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.getRevision() + 1,
-                groupChange.toByteArray());
+                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
@@ -309,11 +364,13 @@ public class GroupHelper {
 
         final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange);
 
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
         return new Pair<>(group.getGroupId(), result);
     }
 
     public SendGroupMessageResults quitGroup(
-            final GroupId groupId, final Set<RecipientId> newAdmins
+            final GroupId groupId,
+            final Set<RecipientId> newAdmins
     ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException {
         var group = getGroupForUpdating(groupId);
         if (group instanceof GroupInfoV1) {
@@ -332,6 +389,7 @@ public class GroupHelper {
     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 {
@@ -342,11 +400,10 @@ public class GroupHelper {
 
         group.setBlocked(blocked);
         account.getGroupStore().updateGroup(group);
+        context.getJobExecutor().enqueueJob(new SyncStorageJob());
     }
 
-    public SendGroupMessageResults sendGroupInfoRequest(
-            GroupIdV1 groupId, RecipientId recipientId
-    ) throws IOException {
+    public SendGroupMessageResults sendGroupInfoRequest(GroupIdV1 groupId, RecipientId recipientId) throws IOException {
         var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize());
 
         var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build());
@@ -356,7 +413,8 @@ public class GroupHelper {
     }
 
     public SendGroupMessageResults sendGroupInfoMessage(
-            GroupIdV1 groupId, RecipientId recipientId
+            GroupIdV1 groupId,
+            RecipientId recipientId
     ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
         GroupInfoV1 g;
         var group = getGroupForUpdating(groupId);
@@ -377,32 +435,44 @@ public class GroupHelper {
 
     private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) {
         final var group = account.getGroupStore().getGroup(groupId);
-        if (group instanceof GroupInfoV2 groupInfoV2) {
-            if (forceUpdate || (!groupInfoV2.isPermissionDenied() && groupInfoV2.getGroup() == null)) {
-                final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-                DecryptedGroup decryptedGroup;
-                try {
-                    decryptedGroup = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams);
-                } catch (NotAGroupMemberException e) {
-                    groupInfoV2.setPermissionDenied(true);
-                    decryptedGroup = null;
-                }
-                if (decryptedGroup != null) {
-                    try {
-                        storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, decryptedGroup);
-                    } catch (NotAGroupMemberException ignored) {
-                    }
-                    storeProfileKeysFromMembers(decryptedGroup);
-                    final var avatar = decryptedGroup.getAvatar();
-                    if (avatar != null && !avatar.isEmpty()) {
-                        downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
-                    }
-                }
-                groupInfoV2.setGroup(decryptedGroup, account.getRecipientResolver());
-                account.getGroupStore().updateGroup(group);
+        fillOrUpdateGroup(group, forceUpdate);
+        return group;
+    }
+
+    private void fillOrUpdateGroup(final GroupInfo group, final boolean forceUpdate) {
+        if (!(group instanceof GroupInfoV2 groupInfoV2)) {
+            return;
+        }
+
+        if (!forceUpdate && (groupInfoV2.isPermissionDenied() || groupInfoV2.getGroup() != null)) {
+            return;
+        }
+
+        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;
         }
-        return group;
+
+        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);
     }
 
     private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) {
@@ -416,7 +486,9 @@ public class GroupHelper {
     }
 
     private void retrieveGroupV2Avatar(
-            GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream
+            GroupSecretParams groupSecretParams,
+            String cdnKey,
+            OutputStream outputStream
     ) throws IOException {
         var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
 
@@ -439,8 +511,8 @@ public class GroupHelper {
     }
 
     private void storeProfileKeysFromMembers(final DecryptedGroup group) {
-        for (var member : group.getMembersList()) {
-            final var serviceId = ServiceId.fromByteString(member.getUuid());
+        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) {
@@ -448,7 +520,7 @@ public class GroupHelper {
                 continue;
             }
             try {
-                profileStore.storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray()));
+                profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
             } catch (InvalidInputException ignored) {
             }
         }
@@ -472,16 +544,20 @@ public class GroupHelper {
             final DecryptedGroup newDecryptedGroup
     ) throws NotAGroupMemberException {
         final var revisionWeWereAdded = context.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup);
-        final var localRevision = localGroup.getGroup() == null ? 0 : localGroup.getGroup().getRevision();
+        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);
-            page.getResults()
+            final var page = context.getGroupV2Helper()
+                    .getDecryptedGroupHistoryPage(groupSecretParams, fromRevision, sendEndorsementsExpirationMs);
+            if (page == null) {
+                break;
+            }
+            page.getChangeLogs()
                     .stream()
-                    .map(DecryptedGroupHistoryEntry::getChange)
-                    .filter(Optional::isPresent)
-                    .map(Optional::get)
+                    .map(DecryptedGroupChangeLog::getChange)
+                    .filter(Objects::nonNull)
                     .map(context.getGroupV2Helper()::getAuthoritativeProfileKeyFromChange)
                     .filter(Objects::nonNull)
                     .forEach(p -> {
@@ -490,13 +566,16 @@ public class GroupHelper {
                         final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
                         newProfileKeys.put(recipientId, profileKey);
                     });
-            if (!page.getPagingData().hasMorePages()) {
+            if (!page.getPagingData().getHasMorePages()) {
                 break;
             }
             fromRevision = page.getPagingData().getNextPageRevision();
         }
 
-        newProfileKeys.forEach(account.getProfileStore()::storeProfileKey);
+        newProfileKeys.entrySet()
+                .stream()
+                .filter(entry -> account.getProfileStore().getProfileKey(entry.getKey()) == null)
+                .forEach(entry -> account.getProfileStore().storeProfileKey(entry.getKey(), entry.getValue()));
     }
 
     private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException {
@@ -507,11 +586,18 @@ public class GroupHelper {
         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;
     }
 
     private SendGroupMessageResults updateGroupV1(
-            final GroupInfoV1 gv1, final String name, final Set<RecipientId> members, final File avatarFile
+            final GroupInfoV1 gv1,
+            final String name,
+            final Set<RecipientId> members,
+            final byte[] avatarFile
     ) throws IOException, AttachmentInvalidException {
         updateGroupV1Details(gv1, name, members, avatarFile);
 
@@ -524,7 +610,10 @@ public class GroupHelper {
     }
 
     private void updateGroupV1Details(
-            final GroupInfoV1 g, final String name, final Collection<RecipientId> members, final File avatarFile
+            final GroupInfoV1 g,
+            final String name,
+            final Collection<RecipientId> members,
+            final byte[] avatarFile
     ) throws IOException {
         if (name != null) {
             g.name = name;
@@ -535,9 +624,7 @@ public class GroupHelper {
         }
 
         if (avatarFile != null) {
-            context.getAvatarStore()
-                    .storeGroupAvatar(g.getGroupId(),
-                            outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream));
+            context.getAvatarStore().storeGroupAvatar(g.getGroupId(), outputStream -> outputStream.write(avatarFile));
         }
     }
 
@@ -545,7 +632,8 @@ public class GroupHelper {
      * Change the expiration timer for a group
      */
     private void setExpirationTimer(
-            GroupInfoV1 groupInfoV1, int messageExpirationTimer
+            GroupInfoV1 groupInfoV1,
+            int messageExpirationTimer
     ) throws NotAGroupMemberException, GroupNotFoundException, IOException, GroupSendingNotAllowedException {
         groupInfoV1.messageExpirationTime = messageExpirationTimer;
         account.getGroupStore().updateGroup(groupInfoV1);
@@ -554,7 +642,7 @@ public class GroupHelper {
 
     private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
         final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
-        context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId);
+        context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty());
     }
 
     private SendGroupMessageResults updateGroupV2(
@@ -571,7 +659,7 @@ public class GroupHelper {
             final GroupLinkState groupLinkState,
             final GroupPermission addMemberPermission,
             final GroupPermission editDetailsPermission,
-            final File avatarFile,
+            final byte[] avatarFile,
             final Integer expirationTimer,
             final Boolean isAnnouncementGroup
     ) throws IOException {
@@ -579,15 +667,28 @@ public class GroupHelper {
         final var groupV2Helper = context.getGroupV2Helper();
         if (group.isPendingMember(account.getSelfRecipientId())) {
             var groupGroupChangePair = groupV2Helper.acceptInvite(group);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         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());
-            if (newMembers.size() > 0) {
+            newMembers.removeAll(group.getRequestingMembers());
+            if (!newMembers.isEmpty()) {
                 var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
-                result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
             }
         }
 
@@ -601,16 +702,28 @@ public class GroupHelper {
                 existingRemoveMembers.removeAll(members);
             }
             existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage
-            if (existingRemoveMembers.size() > 0) {
+            if (!existingRemoveMembers.isEmpty()) {
                 var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers);
-                result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
             }
 
             var pendingRemoveMembers = new HashSet<>(removeMembers);
             pendingRemoveMembers.retainAll(group.getPendingMembers());
-            if (pendingRemoveMembers.size() > 0) {
+            if (!pendingRemoveMembers.isEmpty()) {
                 var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers);
-                result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+                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()));
             }
         }
 
@@ -618,12 +731,12 @@ public class GroupHelper {
             final var newAdmins = new HashSet<>(admins);
             newAdmins.retainAll(group.getMembers());
             newAdmins.removeAll(group.getAdminMembers());
-            if (newAdmins.size() > 0) {
+            if (!newAdmins.isEmpty()) {
                 for (var admin : newAdmins) {
                     var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true);
                     result = sendUpdateGroupV2Message(group,
                             groupGroupChangePair.first(),
-                            groupGroupChangePair.second());
+                            handleGroupChangeResponse(group, groupGroupChangePair.second()));
                 }
             }
         }
@@ -631,12 +744,12 @@ public class GroupHelper {
         if (removeAdmins != null) {
             final var existingRemoveAdmins = new HashSet<>(removeAdmins);
             existingRemoveAdmins.retainAll(group.getAdminMembers());
-            if (existingRemoveAdmins.size() > 0) {
+            if (!existingRemoveAdmins.isEmpty()) {
                 for (var admin : existingRemoveAdmins) {
                     var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false);
                     result = sendUpdateGroupV2Message(group,
                             groupGroupChangePair.first(),
-                            groupGroupChangePair.second());
+                            handleGroupChangeResponse(group, groupGroupChangePair.second()));
                 }
             }
         }
@@ -644,59 +757,76 @@ public class GroupHelper {
         if (banMembers != null) {
             final var newlyBannedMembers = new HashSet<>(banMembers);
             newlyBannedMembers.removeAll(group.getBannedMembers());
-            if (newlyBannedMembers.size() > 0) {
+            if (!newlyBannedMembers.isEmpty()) {
                 var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers);
-                result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
             }
         }
 
         if (unbanMembers != null) {
             var existingUnbanMembers = new HashSet<>(unbanMembers);
             existingUnbanMembers.retainAll(group.getBannedMembers());
-            if (existingUnbanMembers.size() > 0) {
+            if (!existingUnbanMembers.isEmpty()) {
                 var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers);
-                result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+                result = sendUpdateGroupV2Message(group,
+                        groupGroupChangePair.first(),
+                        handleGroupChangeResponse(group, groupGroupChangePair.second()));
             }
         }
 
         if (resetGroupLink) {
             var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         if (groupLinkState != null) {
             var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         if (addMemberPermission != null) {
             var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         if (editDetailsPermission != null) {
             var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         if (expirationTimer != null) {
             var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         if (isAnnouncementGroup != null) {
             var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup);
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            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 -> IOUtils.copyFileToStream(avatarFile, outputStream));
+                        .storeGroupAvatar(group.getGroupId(), outputStream -> outputStream.write(avatarFile));
             }
-            result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
+            result = sendUpdateGroupV2Message(group,
+                    groupGroupChangePair.first(),
+                    handleGroupChangeResponse(group, groupGroupChangePair.second()));
         }
 
         return result;
@@ -716,7 +846,8 @@ public class GroupHelper {
     }
 
     private SendGroupMessageResults quitGroupV2(
-            final GroupInfoV2 groupInfoV2, final Set<RecipientId> newAdmins
+            final GroupInfoV2 groupInfoV2,
+            final Set<RecipientId> newAdmins
     ) throws LastGroupAdminException, IOException {
         final var currentAdmins = groupInfoV2.getAdminMembers();
         newAdmins.removeAll(currentAdmins);
@@ -724,15 +855,16 @@ public class GroupHelper {
         if (currentAdmins.contains(account.getSelfRecipientId())
                 && currentAdmins.size() == 1
                 && groupInfoV2.getMembers().size() > 1
-                && newAdmins.size() == 0) {
+                && 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.getRecipientResolver());
+        groupInfoV2.setGroup(groupGroupChangePair.first());
         account.getGroupStore().updateGroup(groupInfoV2);
 
-        var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
+        var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2,
+                handleGroupChangeResponse(groupInfoV2, groupGroupChangePair.second()).encode());
         return sendGroupMessage(messageBuilder,
                 groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
                 groupInfoV2.getDistributionId());
@@ -761,7 +893,7 @@ public class GroupHelper {
 
     private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) {
         var group = SignalServiceGroupV2.newBuilder(g.getMasterKey())
-                .withRevision(g.getGroup().getRevision())
+                .withRevision(g.getGroup().revision)
                 .withSignedGroupChange(signedGroupChange);
         return SignalServiceDataMessage.newBuilder()
                 .asGroupMessage(group.build())
@@ -769,15 +901,17 @@ public class GroupHelper {
     }
 
     private SendGroupMessageResults sendUpdateGroupV2Message(
-            GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
+            GroupInfoV2 group,
+            DecryptedGroup newDecryptedGroup,
+            GroupChange groupChange
     ) throws IOException {
         final var selfRecipientId = account.getSelfRecipientId();
         final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
-        group.setGroup(newDecryptedGroup, account.getRecipientResolver());
+        group.setGroup(newDecryptedGroup);
         members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
         account.getGroupStore().updateGroup(group);
 
-        final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray());
+        final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode());
         return sendGroupMessage(messageBuilder, members, group.getDistributionId());
     }
 
@@ -796,4 +930,13 @@ public class GroupHelper {
                                 account.getRecipientAddressResolver()))
                         .toList());
     }
+
+    private byte[] readAvatarBytes(final String avatarFile) throws IOException {
+        if (avatarFile == null) {
+            return null;
+        }
+        try (final var avatar = Utils.createStreamDetails(avatarFile).first()) {
+            return IOUtils.readFully(avatar.getStream());
+        }
+    }
 }