package org.tm.archive.groups; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.signal.libsignal.zkgroup.groups.UuidCiphertext; import org.signal.storageservice.protos.groups.GroupExternalCredential; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.tm.archive.database.GroupTable; import org.tm.archive.database.SignalDatabase; import org.tm.archive.database.model.GroupRecord; import org.tm.archive.groups.v2.GroupInviteLinkUrl; import org.tm.archive.groups.v2.GroupLinkPassword; import org.tm.archive.groups.v2.processing.GroupsV2StateProcessor; import org.tm.archive.profiles.AvatarHelper; import org.tm.archive.recipients.Recipient; import org.tm.archive.recipients.RecipientId; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.push.ServiceId; import java.io.IOException; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; public final class GroupManager { private static final String TAG = Log.tag(GroupManager.class); @WorkerThread public static @NonNull GroupActionResult createGroup(@NonNull ServiceId authServiceId, @NonNull Context context, @NonNull Set members, @Nullable byte[] avatar, @Nullable String name, boolean mms, int disappearingMessagesTimer) throws GroupChangeBusyException, GroupChangeFailedException, IOException { boolean shouldAttemptToCreateV2 = !mms; Set memberIds = getMemberIds(members); if (shouldAttemptToCreateV2) { try { try (GroupManagerV2.GroupCreator groupCreator = new GroupManagerV2(context).create()) { return groupCreator.createGroup(authServiceId, memberIds, name, avatar, disappearingMessagesTimer); } } catch (MembershipNotSuitableForV2Exception e) { Log.w(TAG, "Attempted to make a GV2, but membership was not suitable, falling back to GV1", e); return GroupManagerV1.createGroup(context, memberIds, avatar, name, false); } } else { return GroupManagerV1.createGroup(context, memberIds, avatar, name, mms); } } @WorkerThread public static GroupActionResult updateGroupDetails(@NonNull Context context, @NonNull GroupId groupId, @Nullable byte[] avatar, boolean avatarChanged, @NonNull String name, boolean nameChanged, @NonNull String description, boolean descriptionChanged) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { if (groupId.isV2()) { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { return edit.updateGroupTitleDescriptionAndAvatar(nameChanged ? name : null, descriptionChanged ? description : null, avatar, avatarChanged); } } else if (groupId.isV1()) { List members = SignalDatabase.groups() .getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); Set recipientIds = getMemberIds(new HashSet<>(members)); return GroupManagerV1.updateGroup(context, groupId.requireV1(), recipientIds, avatar, name, 0); } else { return GroupManagerV1.updateGroup(context, groupId.requireMms(), avatar, name); } } @WorkerThread public static void migrateGroupToServer(@NonNull Context context, @NonNull GroupId.V1 groupIdV1, @NonNull Collection members) throws IOException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException { new GroupManagerV2(context).migrateGroupOnToServer(groupIdV1, members); } private static Set getMemberIds(Collection recipients) { Set results = new HashSet<>(recipients.size()); for (Recipient recipient : recipients) { results.add(recipient.getId()); } return results; } @WorkerThread public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId, boolean sendToMembers) throws GroupChangeBusyException, GroupChangeFailedException, IOException { if (groupId.isV2()) { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { edit.leaveGroup(sendToMembers); Log.i(TAG, "Left group " + groupId); } catch (GroupInsufficientRightsException e) { Log.w(TAG, "Unexpected prevention from leaving " + groupId + " due to rights", e); throw new GroupChangeFailedException(e); } catch (GroupNotAMemberException e) { Log.w(TAG, "Already left group " + groupId, e); } } else { if (!GroupManagerV1.leaveGroup(context, groupId.requireV1())) { Log.w(TAG, "GV1 group leave failed" + groupId); throw new GroupChangeFailedException(); } } SignalDatabase.recipients().getByGroupId(groupId).ifPresent(id -> SignalDatabase.messages().deleteScheduledMessages(id)); } @WorkerThread public static void leaveGroupFromBlockOrMessageRequest(@NonNull Context context, @NonNull GroupId.Push groupId) throws IOException, GroupChangeBusyException, GroupChangeFailedException { if (groupId.isV2()) { leaveGroup(context, groupId.requireV2(), true); } else { if (!GroupManagerV1.silentLeaveGroup(context, groupId.requireV1())) { throw new GroupChangeFailedException(); } } } @WorkerThread public static void addMemberAdminsAndLeaveGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection newAdmins) throws GroupChangeBusyException, GroupChangeFailedException, IOException, GroupInsufficientRightsException, GroupNotAMemberException { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { edit.addMemberAdminsAndLeaveGroup(newAdmins); Log.i(TAG, "Left group " + groupId); } } @WorkerThread public static void ejectAndBanFromGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Recipient recipient) throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { edit.ejectMember(recipient.requireAci(), false, true, true); Log.i(TAG, "Member removed from group " + groupId); } } /** * @throws GroupNotAMemberException When Self is not a member of the group. * The exception to this is when Self is a requesting member and * there is a supplied signedGroupChange. This allows for * processing deny messages. */ @WorkerThread public static GroupsV2StateProcessor.GroupUpdateResult updateGroupFromServer(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, int revision, long timestamp, @Nullable byte[] signedGroupChange) throws GroupChangeBusyException, IOException, GroupNotAMemberException { try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { return updater.updateLocalToServerRevision(revision, timestamp, null, signedGroupChange); } } @WorkerThread public static GroupsV2StateProcessor.GroupUpdateResult updateGroupFromServer(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @NonNull Optional groupRecord, @Nullable GroupSecretParams groupSecretParams, int revision, long timestamp, @Nullable byte[] signedGroupChange, @Nullable String serverGuid) throws GroupChangeBusyException, IOException, GroupNotAMemberException { try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { return updater.updateLocalToServerRevision(revision, timestamp, groupRecord, groupSecretParams, signedGroupChange, serverGuid); } } @WorkerThread public static void forceSanityUpdateFromServer(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, long timestamp) throws GroupChangeBusyException, IOException, GroupNotAMemberException { try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { updater.forceSanityUpdateFromServer(timestamp); } } @WorkerThread public static V2GroupServerStatus v2GroupStatus(@NonNull Context context, @NonNull ServiceId authServiceId, @NonNull GroupMasterKey groupMasterKey) throws IOException { try { new GroupManagerV2(context).groupServerQuery(authServiceId, groupMasterKey); return V2GroupServerStatus.FULL_OR_PENDING_MEMBER; } catch (GroupNotAMemberException e) { return V2GroupServerStatus.NOT_A_MEMBER; } catch (GroupDoesNotExistException e) { return V2GroupServerStatus.DOES_NOT_EXIST; } } /** * Tries to gets the exact version of the group at the time you joined. *

* If it fails to get the exact version, it will give the latest. */ @WorkerThread public static DecryptedGroup addedGroupVersion(@NonNull ServiceId authServiceId, @NonNull Context context, @NonNull GroupMasterKey groupMasterKey) throws IOException, GroupDoesNotExistException, GroupNotAMemberException { return new GroupManagerV2(context).addedGroupVersion(authServiceId, groupMasterKey); } @WorkerThread public static void setMemberAdmin(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull RecipientId recipientId, boolean admin) throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.setMemberAdmin(recipientId, admin); } } @WorkerThread public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException { if (!SignalDatabase.groups().groupExists(groupId)) { Log.i(TAG, "Group is not available locally " + groupId); return; } try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.updateSelfProfileKeyInGroup(); } } @WorkerThread public static void acceptInvite(@NonNull Context context, @NonNull GroupId.V2 groupId) throws GroupChangeBusyException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.acceptInvite(); SignalDatabase.groups().setActive(groupId, true); } } @WorkerThread public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { if (groupId.isV2()) { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.updateGroupTimer(expirationTime); } } else { GroupManagerV1.updateGroupTimer(context, groupId.requireV1(), expirationTime); } } @WorkerThread public static void revokeInvites(@NonNull Context context, @NonNull ServiceId authServiceId, @NonNull GroupId.V2 groupId, @NonNull Collection uuidCipherTexts) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.revokeInvites(authServiceId, uuidCipherTexts, true); } } @WorkerThread public static void ban(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull RecipientId recipientId) throws GroupChangeBusyException, IOException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException { GroupTable.V2GroupProperties groupProperties = SignalDatabase.groups().requireGroup(groupId).requireV2GroupProperties(); Recipient recipient = Recipient.resolved(recipientId); if (groupProperties.getBannedMembers().contains(recipient.requireServiceId())) { Log.i(TAG, "Attempt to ban already banned recipient: " + recipientId); return; } try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.ban(recipient.requireServiceId()); } } @WorkerThread public static void unban(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull RecipientId recipientId) throws GroupChangeBusyException, IOException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.unban(Collections.singleton(Recipient.resolved(recipientId).requireServiceId())); } } @WorkerThread public static void applyMembershipAdditionRightsChange(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull GroupAccessControl newRights) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.updateMembershipRights(newRights); } } @WorkerThread public static void applyAttributesRightsChange(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull GroupAccessControl newRights) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.updateAttributesRights(newRights); } } @WorkerThread public static void applyAnnouncementGroupChange(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull boolean isAnnouncementGroup) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.updateAnnouncementGroup(isAnnouncementGroup); } } @WorkerThread public static void cycleGroupLinkPassword(@NonNull Context context, @NonNull GroupId.V2 groupId) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.cycleGroupLinkPassword(); } } @WorkerThread public static GroupInviteLinkUrl setGroupLinkEnabledState(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull GroupLinkState state) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { return editor.setJoinByGroupLinkState(state); } } @WorkerThread public static void approveRequests(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection recipientIds) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.approveRequests(recipientIds); } } @WorkerThread public static void denyRequests(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection recipientIds) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.denyRequests(recipientIds); } } @WorkerThread public static @NonNull GroupActionResult addMembers(@NonNull Context context, @NonNull GroupId.Push groupId, @NonNull Collection newMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException, MembershipNotSuitableForV2Exception { if (groupId.isV2()) { GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { return editor.addMembers(newMembers, groupRecord.requireV2GroupProperties().getBannedMembers()); } } else { GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); List members = groupRecord.getMembers(); byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; Set recipientIds = new HashSet<>(members); int originalSize = recipientIds.size(); recipientIds.addAll(newMembers); return GroupManagerV1.updateGroup(context, groupId, recipientIds, avatar, groupRecord.getTitle(), recipientIds.size() - originalSize); } } /** * Use to get a group's details direct from server bypassing the database. *

* Useful when you don't yet have the group in the database locally. */ @WorkerThread public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword groupLinkPassword) throws IOException, VerificationFailedException, GroupLinkNotActiveException { return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword); } @WorkerThread public static GroupActionResult joinGroup(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword groupLinkPassword, @NonNull DecryptedGroupJoinInfo decryptedGroupJoinInfo, @Nullable byte[] avatar) throws IOException, GroupChangeBusyException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException { try (GroupManagerV2.GroupJoiner join = new GroupManagerV2(context).join(groupMasterKey, groupLinkPassword)) { return join.joinGroup(decryptedGroupJoinInfo, avatar); } } @WorkerThread public static void cancelJoinRequest(@NonNull Context context, @NonNull GroupId.V2 groupId) throws GroupChangeFailedException, IOException, GroupChangeBusyException { try (GroupManagerV2.GroupJoiner editor = new GroupManagerV2(context).cancelRequest(groupId.requireV2())) { editor.cancelJoinRequest(); } } public static void sendNoopUpdate(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup currentState) { new GroupManagerV2(context).sendNoopGroupUpdate(groupMasterKey, currentState); } @WorkerThread public static @NonNull GroupExternalCredential getGroupExternalCredential(@NonNull Context context, @NonNull GroupId.V2 groupId) throws IOException, VerificationFailedException { return new GroupManagerV2(context).getGroupExternalCredential(groupId); } @WorkerThread public static @NonNull Map getUuidCipherTexts(@NonNull Context context, @NonNull GroupId.V2 groupId) { return new GroupManagerV2(context).getUuidCipherTexts(groupId); } public static class GroupActionResult { private final Recipient groupRecipient; private final long threadId; private final int addedMemberCount; private final List invitedMembers; public GroupActionResult(@NonNull Recipient groupRecipient, long threadId, int addedMemberCount, @NonNull List invitedMembers) { this.groupRecipient = groupRecipient; this.threadId = threadId; this.addedMemberCount = addedMemberCount; this.invitedMembers = invitedMembers; } public @NonNull Recipient getGroupRecipient() { return groupRecipient; } public long getThreadId() { return threadId; } public int getAddedMemberCount() { return addedMemberCount; } public @NonNull List getInvitedMembers() { return invitedMembers; } } public enum GroupLinkState { DISABLED, 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 } }