diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 9d7106c44d..4f57d551ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import java.io.IOException; @@ -80,6 +79,14 @@ public final class GroupManager { } } + @WorkerThread + public static void migrateGroupToServer(@NonNull Context context, + @NonNull GroupId.V1 groupIdV1) + throws IOException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException + { + new GroupManagerV2(context).migrateGroupOnToServer(groupIdV1); + } + private static Set getMemberIds(Collection recipients) { Set results = new HashSet<>(recipients.size()); @@ -164,6 +171,21 @@ public final class GroupManager { } } + @WorkerThread + public static V2GroupServerStatus v2GroupStatus(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey) + throws IOException + { + try { + new GroupManagerV2(context).groupServerQuery(groupMasterKey); + return V2GroupServerStatus.FULL_OR_PENDING_MEMBER; + } catch (GroupNotAMemberException e) { + return V2GroupServerStatus.NOT_A_MEMBER; + } catch (GroupDoesNotExistException e) { + return V2GroupServerStatus.DOES_NOT_EXIST; + } + } + @WorkerThread public static void setMemberAdmin(@NonNull Context context, @NonNull GroupId.V2 groupId, @@ -303,7 +325,7 @@ public final class GroupManager { } else { GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).requireGroup(groupId); List members = groupRecord.getMembers(); - byte[] avatar = groupRecord.hasAvatar() ? Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId())) : null; + byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; Set recipientIds = new HashSet<>(members); int originalSize = recipientIds.size(); @@ -388,4 +410,13 @@ public final class GroupManager { ENABLED, ENABLED_WITH_APPROVAL } + + public enum V2GroupServerStatus { + /** The group does not exist. The expected pre-migration state for V1 groups. */ + DOES_NOT_EXIST, + /** Group exists but self is not in the group. */ + NOT_A_MEMBER, + /** Self is a full or pending member of the group. */ + FULL_OR_PENDING_MEMBER + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 3cb4f1b220..1790a3a189 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -138,6 +138,31 @@ final class GroupManagerV2 { return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); } + @WorkerThread + void groupServerQuery(@NonNull GroupMasterKey groupMasterKey) + throws GroupNotAMemberException, IOException, GroupDoesNotExistException + { + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .getCurrentGroupStateFromServer(); + } + + @WorkerThread + void migrateGroupOnToServer(@NonNull GroupId.V1 groupIdV1) + throws IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException, GroupChangeFailedException + { + GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupIdV1); + String name = groupRecord.getTitle(); + byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; + int messageTimer = Recipient.resolved(groupRecord.getRecipientId()).getExpireMessages(); + Set memberIds = Stream.of(groupRecord.getMembers()) + .filterNot(m -> m.equals(Recipient.self().getId())) + .collect(Collectors.toSet()); + + createGroupOnServer(groupSecretParams, name, avatar, memberIds, Member.Role.ADMINISTRATOR, messageTimer); + } + final class GroupCreator extends LockOwner { GroupCreator(@NonNull Closeable lock) { @@ -150,59 +175,43 @@ final class GroupManagerV2 { @Nullable byte[] avatar) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception { - if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(members)) { - throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities"); - } + return createGroup(name, avatar, members); + } - GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); - Set candidates = new HashSet<>(groupCandidateHelper.recipientIdsToCandidates(members)); - - if (SignalStore.internalValues().gv2ForceInvites()) { - candidates = GroupCandidate.withoutProfileKeyCredentials(candidates); - } - - if (!self.hasProfileKeyCredential()) { - Log.w(TAG, "Cannot create a V2 group as self does not have a versioned profile"); - throw new MembershipNotSuitableForV2Exception("Cannot create a V2 group as self does not have a versioned profile"); - } - - GroupsV2Operations.NewGroup newGroup = groupsV2Operations.createNewGroup(name, - Optional.fromNullable(avatar), - self, - candidates); - - GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams(); - GroupMasterKey masterKey = groupSecretParams.getMasterKey(); + @WorkerThread + private @NonNull GroupManager.GroupActionResult createGroup(@Nullable String name, + @Nullable byte[] avatar, + @NonNull Collection members) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception + { + GroupSecretParams groupSecretParams = GroupSecretParams.generate(); + DecryptedGroup decryptedGroup; try { - groupsV2Api.putNewGroup(newGroup, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); - - DecryptedGroup decryptedGroup = groupsV2Api.getGroup(groupSecretParams, ApplicationDependencies.getGroupsV2Authorization().getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); - if (decryptedGroup == null) { - throw new GroupChangeFailedException(); - } - - GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup); - RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); - Recipient groupRecipient = Recipient.resolved(groupRecipientId); - - AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); - groupDatabase.onAvatarUpdated(groupId, avatar != null); - DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); - - DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder(GroupChangeReconstruct.reconstructGroupChange(DecryptedGroup.newBuilder().build(), decryptedGroup)) - .setEditor(UuidUtil.toByteString(selfUuid)) - .build(); - - RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); - - return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, - recipientAndThread.threadId, - decryptedGroup.getMembersCount() - 1, - getPendingMemberRecipientIds(decryptedGroup.getPendingMembersList())); - } catch (VerificationFailedException | InvalidGroupStateException | GroupExistsException e) { + decryptedGroup = createGroupOnServer(groupSecretParams, name, avatar, members, Member.Role.DEFAULT, 0); + } catch (GroupAlreadyExistsException e) { throw new GroupChangeFailedException(e); } + + GroupMasterKey masterKey = groupSecretParams.getMasterKey(); + GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup); + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + + AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null); + groupDatabase.onAvatarUpdated(groupId, avatar != null); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); + + DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder(GroupChangeReconstruct.reconstructGroupChange(DecryptedGroup.newBuilder().build(), decryptedGroup)) + .setEditor(UuidUtil.toByteString(selfUuid)) + .build(); + + RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, new GroupMutation(null, groupChange, decryptedGroup), null); + + return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, + recipientAndThread.threadId, + decryptedGroup.getMembersCount() - 1, + getPendingMemberRecipientIds(decryptedGroup.getPendingMembersList())); } } @@ -229,7 +238,7 @@ final class GroupManagerV2 { @NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection newMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception { - if (!GroupsV2CapabilityChecker.allSupportGroupsV2AndUuid(newMembers)) { + if (!GroupsV2CapabilityChecker.allHaveUuidAndSupportGroupsV2(newMembers)) { throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities"); } @@ -586,6 +595,56 @@ final class GroupManagerV2 { } } + @WorkerThread + private @NonNull DecryptedGroup createGroupOnServer(@NonNull GroupSecretParams groupSecretParams, + @Nullable String name, + @Nullable byte[] avatar, + @NonNull Collection members, + @NonNull Member.Role memberRole, + int disappearingMessageTimerSeconds) + throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException + { + if (!GroupsV2CapabilityChecker.allAndSelfHaveUuidAndSupportGroupsV2(members)) { + throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 capability or we don't have their UUID"); + } + + GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId()); + Set candidates = new HashSet<>(groupCandidateHelper.recipientIdsToCandidates(members)); + + if (SignalStore.internalValues().gv2ForceInvites()) { + Log.w(TAG, "Forcing GV2 invites due to internal setting"); + candidates = GroupCandidate.withoutProfileKeyCredentials(candidates); + } + + if (!self.hasProfileKeyCredential()) { + Log.w(TAG, "Cannot create a V2 group as self does not have a versioned profile"); + throw new MembershipNotSuitableForV2Exception("Cannot create a V2 group as self does not have a versioned profile"); + } + + GroupsV2Operations.NewGroup newGroup = groupsV2Operations.createNewGroup(groupSecretParams, + name, + Optional.fromNullable(avatar), + self, + candidates, + memberRole, + disappearingMessageTimerSeconds); + + try { + groupsV2Api.putNewGroup(newGroup, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + + DecryptedGroup decryptedGroup = groupsV2Api.getGroup(groupSecretParams, ApplicationDependencies.getGroupsV2Authorization().getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)); + if (decryptedGroup == null) { + throw new GroupChangeFailedException(); + } + + return decryptedGroup; + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new GroupChangeFailedException(e); + } catch (GroupExistsException e) { + throw new GroupAlreadyExistsException(e); + } + } + final class GroupJoiner extends LockOwner { private final GroupId.V2 groupId; private final GroupLinkPassword password; @@ -777,7 +836,7 @@ final class GroupManagerV2 { private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException { - if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) { + if (!GroupsV2CapabilityChecker.allAndSelfHaveUuidAndSupportGroupsV2(Collections.singleton(Recipient.self().getId()))) { throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java index a5d43a6dbb..506c9bb698 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java @@ -52,18 +52,18 @@ public final class GroupsV2CapabilityChecker { } @WorkerThread - static boolean allAndSelfSupportGroupsV2AndUuid(@NonNull Collection recipientIds) + static boolean allAndSelfHaveUuidAndSupportGroupsV2(@NonNull Collection recipientIds) throws IOException { HashSet recipientIdsSet = new HashSet<>(recipientIds); recipientIdsSet.add(Recipient.self().getId()); - return allSupportGroupsV2AndUuid(recipientIdsSet); + return allHaveUuidAndSupportGroupsV2(recipientIdsSet); } @WorkerThread - static boolean allSupportGroupsV2AndUuid(@NonNull Collection recipientIds) + static boolean allHaveUuidAndSupportGroupsV2(@NonNull Collection recipientIds) throws IOException { Set recipientIdsSet = new HashSet<>(recipientIds); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java index 12790a0152..3649202971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -96,6 +96,11 @@ public class AvatarHelper { return ModernDecryptingPartInputStream.createFor(attachmentSecret, avatarFile, 0); } + public static byte[] getAvatarBytes(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException { + return hasAvatar(context, recipientId) ? Util.readFully(getAvatar(context, recipientId)) + : null; + } + /** * Returns the size of the avatar on disk. */ diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index f12f6d5de0..a147ba5fc7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -79,23 +79,26 @@ public final class GroupsV2Operations { * @param self You will be member 0 and the only admin. * @param members Members must not contain self. Members will be non-admin members of the group. */ - public NewGroup createNewGroup(final String title, + public NewGroup createNewGroup(final GroupSecretParams groupSecretParams, + final String title, final Optional avatar, final GroupCandidate self, - final Set members) { + final Set members, + final Member.Role memberRole, + final int disappearingMessageTimerSeconds) + { if (members.contains(self)) { throw new IllegalArgumentException("Members must not contain self"); } - final GroupSecretParams groupSecretParams = GroupSecretParams.generate(random); - final GroupOperations groupOperations = forGroup(groupSecretParams); + final GroupOperations groupOperations = forGroup(groupSecretParams); Group.Builder group = Group.newBuilder() .setRevision(0) .setPublicKey(ByteString.copyFrom(groupSecretParams.getPublicParams().serialize())) .setTitle(groupOperations.encryptTitle(title)) - .setDisappearingMessagesTimer(groupOperations.encryptTimer(0)) + .setDisappearingMessagesTimer(groupOperations.encryptTimer(disappearingMessageTimerSeconds)) .setAccessControl(AccessControl.newBuilder() .setAttributes(AccessControl.AccessRequired.MEMBER) .setMembers(AccessControl.AccessRequired.MEMBER)); @@ -103,13 +106,12 @@ public final class GroupsV2Operations { group.addMembers(groupOperations.member(self.getProfileKeyCredential().get(), Member.Role.ADMINISTRATOR)); for (GroupCandidate credential : members) { - Member.Role newMemberRole = Member.Role.DEFAULT; ProfileKeyCredential profileKeyCredential = credential.getProfileKeyCredential().orNull(); if (profileKeyCredential != null) { - group.addMembers(groupOperations.member(profileKeyCredential, newMemberRole)); + group.addMembers(groupOperations.member(profileKeyCredential, memberRole)); } else { - group.addPendingMembers(groupOperations.invitee(credential.getUuid(), newMemberRole)); + group.addPendingMembers(groupOperations.invitee(credential.getUuid(), memberRole)); } }