Join group via invite link.

This commit is contained in:
Alan Evans
2020-08-26 12:51:25 -03:00
committed by GitHub
parent b58376920f
commit 860f06ec9e
45 changed files with 2488 additions and 271 deletions

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
public final class GroupJoinAlreadyAMemberException extends GroupChangeException {
GroupJoinAlreadyAMemberException(@NonNull Throwable throwable) {
super(throwable);
}
}

View File

@@ -145,6 +145,12 @@ public final class GroupManager {
}
}
/**
* @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 void updateGroupFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@@ -174,6 +180,11 @@ public final class GroupManager {
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
{
if (!DatabaseFactory.getGroupDatabase(context).findGroup(groupId)) {
Log.i(TAG, "Group is not available locally " + groupId);
return;
}
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateSelfProfileKeyInGroup();
}
@@ -266,12 +277,35 @@ public final class GroupManager {
@WorkerThread
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull GroupLinkPassword groupLinkPassword)
@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 class GroupActionResult {
private final Recipient groupRecipient;
private final long threadId;

View File

@@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.storageservice.protos.groups.AccessControl;
@@ -17,21 +19,25 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
@@ -62,6 +68,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@@ -89,12 +96,14 @@ final class GroupManagerV2 {
this.groupCandidateHelper = new GroupCandidateHelper(context);
}
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password)
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password)
throws IOException, VerificationFailedException, GroupLinkNotActiveException
{
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
return groupsV2Api.getGroupJoinInfo(groupSecretParams, password.serialize(), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
}
@WorkerThread
@@ -107,17 +116,30 @@ final class GroupManagerV2 {
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException {
return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException {
GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context)
.requireGroup(groupId)
.requireV2GroupProperties()
.getGroupMasterKey();
return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
class GroupCreator implements Closeable {
private final Closeable lock;
final class GroupCreator extends LockOwner {
GroupCreator(@NonNull Closeable lock) {
this.lock = lock;
super(lock);
}
@WorkerThread
@@ -176,26 +198,21 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException(e);
}
}
@Override
public void close() throws IOException {
lock.close();
}
}
class GroupEditor implements Closeable {
final class GroupEditor extends LockOwner {
private final Closeable lock;
private final GroupId.V2 groupId;
private final GroupMasterKey groupMasterKey;
private final GroupSecretParams groupSecretParams;
private final GroupsV2Operations.GroupOperations groupOperations;
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
super(lock);
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
this.lock = lock;
this.groupId = groupId;
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
@@ -275,6 +292,17 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection<RecipientId> recipientIds)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Set<UUID> uuids = Stream.of(recipientIds)
.map(r -> Recipient.resolved(r).getUuid().get())
.collect(Collectors.toSet());
return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
boolean admin)
@@ -333,7 +361,7 @@ final class GroupManagerV2 {
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
if (!selfInGroup.isPresent()) {
Log.w(TAG, "Self not in group");
Log.w(TAG, "Self not in group " + groupId);
return null;
}
@@ -456,7 +484,7 @@ final class GroupManagerV2 {
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
try {
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent());
} catch (NotInGroupException e) {
Log.w(TAG, e);
throw new GroupNotAMemberException(e);
@@ -468,20 +496,15 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException(e);
}
}
@Override
public void close() throws IOException {
lock.close();
}
}
class GroupUpdater implements Closeable {
final class GroupUpdater extends LockOwner {
private final Closeable lock;
private final GroupMasterKey groupMasterKey;
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
this.lock = lock;
super(lock);
this.groupMasterKey = groupMasterKey;
}
@@ -507,6 +530,377 @@ final class GroupManagerV2 {
return null;
}
}
final class GroupJoiner extends LockOwner {
private final GroupId.V2 groupId;
private final GroupLinkPassword password;
private final GroupSecretParams groupSecretParams;
private final GroupsV2Operations.GroupOperations groupOperations;
private final GroupMasterKey groupMasterKey;
public GroupJoiner(@NonNull GroupMasterKey groupMasterKey,
@Nullable GroupLinkPassword password,
@NonNull Closeable lock)
{
super(lock);
this.groupId = GroupId.v2(groupMasterKey);
this.password = password;
this.groupMasterKey = groupMasterKey;
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
}
@WorkerThread
public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo,
@Nullable byte[] avatar)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
{
boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
boolean alreadyAMember = false;
if (requestToJoin) {
Log.i(TAG, "Requesting to join " + groupId);
} else {
Log.i(TAG, "Joining " + groupId);
}
GroupChange signedGroupChange = null;
DecryptedGroupChange decryptedChange = null;
try {
signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision());
if (requestToJoin) {
Log.i(TAG, String.format("Successfully requested to join %s on server", groupId));
} else {
Log.i(TAG, String.format("Successfully added self to %s on server", groupId));
}
decryptedChange = decryptChange(signedGroupChange);
} catch (GroupJoinAlreadyAMemberException e) {
Log.i(TAG, "Server reports that we are already a member of " + groupId);
alreadyAMember = true;
}
DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin);
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
if (group.isPresent()) {
Log.i(TAG, "Group already present locally");
DecryptedGroup currentGroupState = group.get()
.requireV2GroupProperties()
.getDecryptedGroup();
DecryptedGroup updatedGroup = currentGroupState;
try {
if (decryptedChange != null) {
updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange);
}
updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision());
} catch (NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, e);
updatedGroup = decryptedGroup;
}
groupDatabase.update(groupId, updatedGroup);
} else {
groupDatabase.create(groupMasterKey, decryptedGroup);
Log.i(TAG, "Created local group with placeholder");
}
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
groupDatabase.onAvatarUpdated(groupId, avatar != null);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true);
if (alreadyAMember) {
Log.i(TAG, "Already a member of the group");
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1);
return new GroupManager.GroupActionResult(groupRecipient,
threadId,
0,
Collections.emptyList());
} else if (requestToJoin) {
Log.i(TAG, "Requested to join, cannot send update");
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
0,
Collections.emptyList());
} else {
Log.i(TAG, "Joined group on server, fetching group state and sending update");
return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange);
}
}
private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient,
@NonNull DecryptedGroup decryptedGroup,
@NonNull DecryptedGroupChange decryptedChange,
@NonNull GroupChange signedGroupChange)
throws GroupChangeFailedException, IOException
{
try {
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
.updateLocalGroupToRevision(decryptedChange.getRevision(),
System.currentTimeMillis(),
decryptedChange);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
1,
Collections.emptyList());
} catch (GroupNotAMemberException e) {
Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e);
ApplicationDependencies.getJobManager()
.add(new RequestGroupV2InfoJob(groupId));
throw new GroupChangeFailedException(e);
} catch (IOException e) {
Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e);
ApplicationDependencies.getJobManager()
.add(new RequestGroupV2InfoJob(groupId));
throw e;
}
}
private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange)
throws GroupChangeFailedException
{
try {
return groupOperations.decryptChange(signedGroupChange, false).get();
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
Log.w(TAG, e);
throw new GroupChangeFailedException(e);
}
}
/**
* Creates a local group from what we know before joining.
* <p>
* Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a
* full diff against this group once we learn more about this group as that would create a large
* update message.
*/
private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) {
DecryptedGroup.Builder group = DecryptedGroup.newBuilder()
.setTitle(joinInfo.getTitle())
.setAvatar(joinInfo.getAvatar())
.setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION);
Recipient self = Recipient.self();
ByteString selfUuid = UuidUtil.toByteString(self.requireUuid());
ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey()));
if (requestToJoin) {
group.addRequestingMembers(DecryptedRequestingMember.newBuilder()
.setUuid(selfUuid)
.setProfileKey(profileKey));
} else {
group.addMembers(DecryptedMember.newBuilder()
.setUuid(selfUuid)
.setProfileKey(profileKey));
}
return group.build();
}
private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
{
if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) {
throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities");
}
GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
if (!self.hasProfileKeyCredential()) {
throw new MembershipNotSuitableForV2Exception("No profile key credential for self");
}
ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get();
GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential)
: groupOperations.createGroupJoinDirect(profileKeyCredential);
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
return commitJoinChangeWithConflictResolution(currentRevision, change);
}
private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
{
for (int attempt = 0; attempt < 5; attempt++) {
try {
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
.build();
Log.i(TAG, "Trying to join group at V" + changeActions.getRevision());
GroupChange signedGroupChange = commitJoinToServer(changeActions);
Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision());
return signedGroupChange;
} catch (GroupPatchNotAcceptedException e) {
Log.w(TAG, "Patch not accepted", e);
try {
if (alreadyPendingAdminApproval() || testGroupMembership()) {
throw new GroupJoinAlreadyAMemberException(e);
} else {
throw new GroupChangeFailedException(e);
}
} catch (VerificationFailedException | InvalidGroupStateException ex) {
throw new GroupChangeFailedException(ex);
}
} catch (ConflictException e) {
Log.w(TAG, "Revision conflict", e);
currentRevision = getCurrentGroupRevisionFromServer();
}
}
throw new GroupChangeFailedException("Unable to join group after conflicts");
}
private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
{
try {
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
} catch (NotInGroupException | VerificationFailedException e) {
Log.w(TAG, e);
throw new GroupChangeFailedException(e);
} catch (AuthorizationFailedException e) {
Log.w(TAG, e);
throw new GroupLinkNotActiveException(e);
}
}
private int getCurrentGroupRevisionFromServer()
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
{
try {
int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision();
Log.i(TAG, "Server now on V" + currentRevision);
return currentRevision;
} catch (VerificationFailedException ex) {
throw new GroupChangeFailedException(ex);
}
}
private boolean alreadyPendingAdminApproval()
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
{
try {
boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval();
if (pendingAdminApproval) {
Log.i(TAG, "User is already pending admin approval");
}
return pendingAdminApproval;
} catch (VerificationFailedException ex) {
throw new GroupChangeFailedException(ex);
}
}
private boolean testGroupMembership()
throws IOException, VerificationFailedException, InvalidGroupStateException
{
try {
groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
return true;
} catch (NotInGroupException ex) {
return false;
}
}
@WorkerThread
void cancelJoinRequest()
throws GroupChangeFailedException, IOException
{
Set<UUID> uuids = Collections.singleton(Recipient.self().getUuid().get());
GroupChange signedGroupChange;
try {
signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
} catch (GroupLinkNotActiveException e) {
Log.d(TAG, "Unexpected unable to leave group due to group link off");
throw new GroupChangeFailedException(e);
}
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
try {
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get();
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision()));
sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new GroupChangeFailedException(e);
}
}
private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) {
return DecryptedGroup.newBuilder(newGroup)
.setRevision(revision)
.build();
}
private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
{
int currentRevision = getCurrentGroupRevisionFromServer();
for (int attempt = 0; attempt < 5; attempt++) {
try {
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
.build();
Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision());
GroupChange signedGroupChange = commitJoinToServer(changeActions);
Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision());
return signedGroupChange;
} catch (GroupPatchNotAcceptedException e) {
throw new GroupChangeFailedException(e);
} catch (ConflictException e) {
Log.w(TAG, "Revision conflict", e);
currentRevision = getCurrentGroupRevisionFromServer();
}
}
throw new GroupChangeFailedException("Unable to cancel group join request after conflicts");
}
}
private abstract static class LockOwner implements Closeable {
final Closeable lock;
LockOwner(@NonNull Closeable lock) {
this.lock = lock;
}
@Override
public void close() throws IOException {

View File

@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.groups.ui;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
@@ -9,7 +9,11 @@ public final class GroupErrors {
private GroupErrors() {
}
public static @StringRes int getUserDisplayMessage(@NonNull GroupChangeFailureReason failureReason) {
public static @StringRes int getUserDisplayMessage(@Nullable GroupChangeFailureReason failureReason) {
if (failureReason == null) {
return R.string.ManageGroupActivity_failed_to_update_the_group;
}
switch (failureReason) {
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;

View File

@@ -1,42 +1,39 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
public final class GroupDetails {
private final String groupName;
private final byte[] avatarBytes;
private final int groupMembershipCount;
private final boolean requiresAdminApproval;
private final int groupRevision;
private final DecryptedGroupJoinInfo joinInfo;
private final byte[] avatarBytes;
public GroupDetails(String groupName,
byte[] avatarBytes,
int groupMembershipCount,
boolean requiresAdminApproval,
int groupRevision)
public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo,
@Nullable byte[] avatarBytes)
{
this.groupName = groupName;
this.avatarBytes = avatarBytes;
this.groupMembershipCount = groupMembershipCount;
this.requiresAdminApproval = requiresAdminApproval;
this.groupRevision = groupRevision;
this.joinInfo = joinInfo;
this.avatarBytes = avatarBytes;
}
public String getGroupName() {
return groupName;
public @NonNull String getGroupName() {
return joinInfo.getTitle();
}
public byte[] getAvatarBytes() {
public @Nullable byte[] getAvatarBytes() {
return avatarBytes;
}
public @NonNull DecryptedGroupJoinInfo getJoinInfo() {
return joinInfo;
}
public int getGroupMembershipCount() {
return groupMembershipCount;
return joinInfo.getMemberCount();
}
public boolean joinRequiresAdminApproval() {
return requiresAdminApproval;
}
public int getGroupRevision() {
return groupRevision;
return joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -22,7 +23,9 @@ import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
@@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
private static final String TAG = Log.tag(GroupJoinUpdateRequiredBottomSheetDialogFragment.class);
private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
private ProgressBar busy;
@@ -93,14 +98,13 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
groupName.setText(details.getGroupName());
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
switch (FeatureFlags.clientLocalGroupJoinStatus()) {
switch (getGroupJoinStatus()) {
case COMING_SOON:
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
groupCancelButton.setText(android.R.string.ok);
groupJoinButton.setVisibility(View.GONE);
break;
case UPDATE_TO_JOIN:
case LOCAL_CAN_JOIN:
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
groupJoinButton.setOnClickListener(v -> {
@@ -109,6 +113,17 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
});
groupJoinButton.setVisibility(View.VISIBLE);
break;
case LOCAL_CAN_JOIN:
groupJoinExplain.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_admin_approval_needed
: R.string.GroupJoinBottomSheetDialogFragment_direct_join);
groupJoinButton.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_request_to_join
: R.string.GroupJoinBottomSheetDialogFragment_join);
groupJoinButton.setOnClickListener(v -> {
Log.i(TAG, details.joinRequiresAdminApproval() ? "Attempting to direct join group" : "Attempting to request to join group");
viewModel.join(details);
});
groupJoinButton.setVisibility(View.VISIBLE);
break;
}
avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL);
@@ -117,19 +132,55 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
});
viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
dismiss();
});
viewModel.getJoinErrors().observe(getViewLifecycleOwner(), error -> Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show());
viewModel.getJoinSuccess().observe(getViewLifecycleOwner(), joinGroupSuccess -> {
Log.i(TAG, "Group joined, navigating to group");
Intent intent = ConversationActivity.buildIntent(requireContext(), joinGroupSuccess.getGroupRecipient().getId(), joinGroupSuccess.getGroupThreadId());
requireActivity().startActivity(intent);
dismiss();
}
);
}
protected @NonNull String errorToMessage(FetchGroupDetailsError error) {
private static FeatureFlags.GroupJoinStatus getGroupJoinStatus() {
FeatureFlags.GroupJoinStatus groupJoinStatus = FeatureFlags.clientLocalGroupJoinStatus();
if (groupJoinStatus == FeatureFlags.GroupJoinStatus.LOCAL_CAN_JOIN) {
if (!FeatureFlags.groupsV2() || Recipient.self().getGroupsV2Capability() == Recipient.Capability.NOT_SUPPORTED) {
// TODO [Alan] GV2 additional copy could be presented in these cases
return FeatureFlags.GroupJoinStatus.UPDATE_TO_JOIN;
}
return groupJoinStatus;
}
return groupJoinStatus;
}
private @NonNull String errorToMessage(@NonNull FetchGroupDetailsError error) {
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
}
return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later);
}
private @NonNull String errorToMessage(@NonNull JoinGroupError error) {
switch (error) {
case GROUP_LINK_NOT_ACTIVE: return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
}
}
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
try {
//noinspection ConstantConditions

View File

@@ -5,15 +5,17 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.zkgroup.VerificationFailedException;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
@@ -31,7 +33,7 @@ final class GroupJoinRepository {
this.groupInviteLinkUrl = groupInviteLinkUrl;
}
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError> callback) {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
callback.onComplete(getGroupDetails());
@@ -43,6 +45,30 @@ final class GroupJoinRepository {
});
}
void joinGroup(@NonNull GroupDetails groupDetails,
@NonNull AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError> callback)
{
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.GroupActionResult groupActionResult = GroupManager.joinGroup(context,
groupInviteLinkUrl.getGroupMasterKey(),
groupInviteLinkUrl.getPassword(),
groupDetails.getJoinInfo(),
groupDetails.getAvatarBytes());
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
} catch (IOException e) {
callback.onError(JoinGroupError.NETWORK_ERROR);
} catch (GroupChangeBusyException e) {
callback.onError(JoinGroupError.BUSY);
} catch (GroupLinkNotActiveException e) {
callback.onError(JoinGroupError.GROUP_LINK_NOT_ACTIVE);
} catch (GroupChangeFailedException | MembershipNotSuitableForV2Exception e) {
callback.onError(JoinGroupError.FAILED);
}
});
}
@WorkerThread
private @NonNull GroupDetails getGroupDetails()
throws VerificationFailedException, IOException, GroupLinkNotActiveException
@@ -51,14 +77,9 @@ final class GroupJoinRepository {
groupInviteLinkUrl.getGroupMasterKey(),
groupInviteLinkUrl.getPassword());
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
return new GroupDetails(joinInfo.getTitle(),
avatarBytes,
joinInfo.getMemberCount(),
requiresAdminApproval,
joinInfo.getRevision());
return new GroupDetails(joinInfo, avatarBytes);
}
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
@@ -69,9 +90,4 @@ final class GroupJoinRepository {
return null;
}
}
interface GetGroupDetailsCallback {
void onComplete(@NonNull GroupDetails groupDetails);
void onError(@NonNull FetchGroupDetailsError error);
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.MutableLiveData;
@@ -10,35 +11,62 @@ import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
public class GroupJoinViewModel extends ViewModel {
private final GroupJoinRepository repository;
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
private final MutableLiveData<JoinGroupError> joinErrors = new SingleLiveEvent<>();
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
private final MutableLiveData<JoinGroupSuccess> joinSuccess = new SingleLiveEvent<>();
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
this.repository = repository;
busy.setValue(true);
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
repository.getGroupDetails(new AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError>() {
@Override
public void onComplete(@NonNull GroupDetails details) {
public void onComplete(@Nullable GroupDetails details) {
busy.postValue(false);
groupDetails.postValue(details);
}
@Override
public void onError(@NonNull FetchGroupDetailsError error) {
public void onError(@Nullable FetchGroupDetailsError error) {
busy.postValue(false);
errors.postValue(error);
}
});
}
void join(@NonNull GroupDetails groupDetails) {
busy.setValue(true);
repository.joinGroup(groupDetails, new AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError>() {
@Override
public void onComplete(@Nullable JoinGroupSuccess result) {
busy.postValue(false);
joinSuccess.postValue(result);
}
@Override
public void onError(@Nullable JoinGroupError error) {
busy.postValue(false);
joinErrors.postValue(error);
}
});
}
LiveData<GroupDetails> getGroupDetails() {
return groupDetails;
}
LiveData<JoinGroupSuccess> getJoinSuccess() {
return joinSuccess;
}
LiveData<Boolean> isBusy() {
return busy;
}
@@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel {
return errors;
}
LiveData<JoinGroupError> getJoinErrors() {
return joinErrors;
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
enum JoinGroupError {
BUSY,
GROUP_LINK_NOT_ACTIVE,
FAILED,
NETWORK_ERROR,
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import org.thoughtcrime.securesms.recipients.Recipient;
final class JoinGroupSuccess {
private final Recipient groupRecipient;
private final long groupThreadId;
JoinGroupSuccess(Recipient groupRecipient, long groupThreadId) {
this.groupRecipient = groupRecipient;
this.groupThreadId = groupThreadId;
}
Recipient getGroupRecipient() {
return groupRecipient;
}
long getGroupThreadId() {
return groupThreadId;
}
}

View File

@@ -1,25 +1,28 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.thoughtcrime.securesms.logging.Log;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Objects;
import java.util.List;
final class GroupStateMapper {
private static final String TAG = Log.tag(GroupStateMapper.class);
static final int LATEST = Integer.MAX_VALUE;
static final int LATEST = Integer.MAX_VALUE;
static final int PLACEHOLDER_REVISION = -1;
private static final Comparator<ServerGroupLogEntry> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
@@ -36,10 +39,18 @@ final class GroupStateMapper {
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
int maximumRevisionToApply)
{
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(inputState.getServerHistory().size());
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
DecryptedGroup current = inputState.getLocalState();
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
}
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState,
int maximumRevisionToApply)
{
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
DecryptedGroup current = inputState.getLocalState();
StateChain<DecryptedGroup, DecryptedGroupChange> stateChain = createNewMapper();
if (inputState.getServerHistory().isEmpty()) {
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
@@ -55,9 +66,15 @@ final class GroupStateMapper {
Collections.sort(statesToApplyLater, BY_REVISION);
final int from = inputState.getEarliestRevisionNumber();
final int from = Math.max(0, inputState.getEarliestRevisionNumber());
final int to = Math.min(inputState.getLatestRevisionNumber(), maximumRevisionToApply);
if (current != null && current.getRevision() == PLACEHOLDER_REVISION) {
Log.i(TAG, "Ignoring place holder group state");
} else {
stateChain.push(current, null);
}
for (int revision = from; revision >= 0 && revision <= to; revision++) {
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
if (entry == null) {
@@ -65,59 +82,64 @@ final class GroupStateMapper {
continue;
}
DecryptedGroup groupAtRevision = entry.getGroup();
DecryptedGroupChange changeAtRevision = entry.getChange();
if (stateChain.getLatestState() == null && entry.getGroup() != null && current != null && current.getRevision() == PLACEHOLDER_REVISION) {
DecryptedGroup previousState = DecryptedGroup.newBuilder(entry.getGroup())
.setTitle(current.getTitle())
.setAvatar(current.getAvatar())
.build();
if (current == null) {
Log.w(TAG, "No local state, accepting server state for V" + revision);
current = groupAtRevision;
if (groupAtRevision != null) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
}
continue;
stateChain.push(previousState, null);
}
if (current.getRevision() + 1 != revision) {
Log.w(TAG, "Detected gap V" + revision);
}
if (changeAtRevision == null) {
Log.w(TAG, "Reconstructing change for V" + revision);
changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision));
}
DecryptedGroup groupWithChangeApplied;
try {
groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision);
} catch (NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, "Unable to apply V" + revision, e);
continue;
}
if (groupAtRevision == null) {
Log.w(TAG, "Reconstructing state for V" + revision);
groupAtRevision = groupWithChangeApplied;
}
if (current.getRevision() != groupAtRevision.getRevision()) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
} else {
DecryptedGroupChange sameRevisionDelta = GroupChangeReconstruct.reconstructGroupChange(current, groupAtRevision);
if (!DecryptedGroupUtil.changeIsEmpty(sameRevisionDelta)) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, sameRevisionDelta));
Log.w(TAG, "Inserted repair change for mismatch V" + revision);
}
}
DecryptedGroupChange missingChanges = GroupChangeReconstruct.reconstructGroupChange(groupWithChangeApplied, groupAtRevision);
if (!DecryptedGroupUtil.changeIsEmpty(missingChanges)) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, missingChanges));
Log.w(TAG, "Inserted repair change for gap V" + revision);
}
current = groupAtRevision;
stateChain.push(entry.getGroup(), entry.getChange());
}
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(current, statesToApplyLater));
List<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
if (current == null || entry.getDelta() != null) {
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
}
}
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
}
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
@Nullable DecryptedGroup previousGroupState)
{
if (previousGroupState == null) return groupStateResult;
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
DecryptedGroupChange change = entry.getChange();
if (change != null) {
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
}
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
previousGroupState = entry.getGroup();
}
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
}
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {
return new StateChain<>(
(group, change) -> {
try {
return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change);
} catch (NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, "Unable to apply V" + change.getRevision(), e);
return null;
}
},
(groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB),
(groupA, groupB) -> DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB))
);
}
}

View File

@@ -17,9 +17,7 @@ import org.signal.zkgroup.groups.GroupSecretParams;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
@@ -65,7 +63,8 @@ public final class GroupsV2StateProcessor {
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
public static final int LATEST = GroupStateMapper.LATEST;
public static final int LATEST = GroupStateMapper.LATEST;
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
private final Context context;
private final JobManager jobManager;
@@ -177,9 +176,26 @@ public final class GroupsV2StateProcessor {
try {
inputGroupState = queryServer(localState, revision == LATEST && localState == null);
} catch (GroupNotAMemberException e) {
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
insertGroupLeave();
throw e;
if (localState != null && signedGroupChange != null) {
try {
Log.i(TAG, "Applying P2P group change when not a member");
DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange);
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
} catch (NotAbleToApplyGroupV2ChangeException failed) {
Log.w(TAG, "Unable to apply P2P group change when not a member", failed);
}
}
if (inputGroupState == null) {
if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, Recipient.self().getUuid().get())) {
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member");
} else {
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
insertGroupLeave();
}
throw e;
}
}
} else {
Log.i(TAG, "Saved server query for group change");

View File

@@ -0,0 +1,172 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
/**
* Maintains a chain of state pairs:
* <pre>
* {@code
* (S1, Delta1),
* (S2, Delta2),
* (S3, Delta3)
* }
* </pre>
* Such that the states always include all deltas.
* <pre>
* {@code
* (S1, _),
* (S1 + Delta2, Delta2),
* (S1 + Delta2 + Delta3, Delta3),
* }
* </pre>
* <p>
* If a pushed delta does not correct create the new state (tested by {@link StateEquality}), a new
* delta and state is inserted like so:
* <pre>
* {@code
* (PreviousState, PreviousDelta),
* (PreviousState + NewDelta, NewDelta),
* (NewState, PreviousState + NewDelta - NewState),
* }
* </pre>
* That is it keeps both the newly supplied delta and state, but creates an interim state and delta.
*
* The + function is supplied by {@link AddDelta} and the - function is supplied by {@link SubtractStates}.
*/
public final class StateChain<State, Delta> {
private final AddDelta<State, Delta> add;
private final SubtractStates<State, Delta> subtract;
private final StateEquality<State> stateEquality;
private final List<Pair<State, Delta>> pairs = new LinkedList<>();
public StateChain(@NonNull AddDelta<State, Delta> add,
@NonNull SubtractStates<State, Delta> subtract,
@NonNull StateEquality<State> stateEquality)
{
this.add = add;
this.subtract = subtract;
this.stateEquality = stateEquality;
}
public void push(@Nullable State state, @Nullable Delta delta) {
if (delta == null && state == null) return;
boolean bothSupplied = state != null && delta != null;
State latestState = getLatestState();
if (latestState == null && state == null) return;
if (latestState != null) {
if (delta == null) {
delta = subtract.subtract(state, latestState);
}
if (state == null) {
state = add.add(latestState, delta);
if (state == null) return;
}
if (bothSupplied) {
State calculatedState = add.add(latestState, delta);
if (calculatedState == null) {
push(state, null);
return;
} else if (!stateEquality.equals(state, calculatedState)) {
push(null, delta);
push(state, null);
return;
}
}
}
if (latestState == null || !stateEquality.equals(latestState, state)) {
pairs.add(new Pair<>(state, delta));
}
}
public @Nullable State getLatestState() {
int size = pairs.size();
return size == 0 ? null : pairs.get(size - 1).getState();
}
public List<Pair<State, Delta>> getList() {
return new ArrayList<>(pairs);
}
public static final class Pair<State, Delta> {
@NonNull private final State state;
@Nullable private final Delta delta;
Pair(@NonNull State state, @Nullable Delta delta) {
this.state = state;
this.delta = delta;
}
public @NonNull State getState() {
return state;
}
public @Nullable Delta getDelta() {
return delta;
}
@Override
public String toString() {
return String.format("(%s, %s)", state, delta);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Pair<?, ?> other = (Pair<?, ?>) o;
return state.equals(other.state) &&
Objects.equals(delta, other.delta);
}
@Override
public int hashCode() {
int result = state.hashCode();
result = 31 * result + (delta != null ? delta.hashCode() : 0);
return result;
}
}
interface AddDelta<State, Delta> {
/**
* Add {@param delta} to {@param state} and return the new {@link State}.
* <p>
* If this returns null, then the delta could not be applied and will be ignored.
*/
@Nullable State add(@NonNull State state, @NonNull Delta delta);
}
interface SubtractStates<State, Delta> {
/**
* Finds a delta = {@param stateB} - {@param stateA}
* such that {@param stateA} + {@link Delta} = {@param stateB}.
*/
@NonNull Delta subtract(@NonNull State stateB, @NonNull State stateA);
}
interface StateEquality<State> {
boolean equals(@NonNull State stateA, @NonNull State stateB);
}
}